diff --git a/CHANGELOG.md b/CHANGELOG.md index 32543f60d20..27890f9e169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional `memory-wiki` gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky. - Agents/tools: only mark streamed unknown-tool retries as counted when a streamed message actually classifies an unavailable tool, and keep incomplete streamed tool names from resetting the retry streak before the final assistant message arrives. (#66145) Thanks @dutifulbob. - Memory/active-memory: move recalled memory onto the hidden untrusted prompt-prefix path instead of system prompt injection, label the visible Active Memory status line fields, and include the resolved recall provider/model in gateway debug logs so trace/debug output matches what the model actually saw. +- Memory/QMD: stop treating legacy lowercase `memory.md` as a second default root collection, so QMD recall no longer searches phantom `memory-alt-*` collections and builtin/QMD root-memory fallback stays aligned. (#66141) Thanks @mbelinky. ## 2026.4.12 diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 920b70fe1e4..2d19abe5d90 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -797,6 +797,8 @@ describe("QmdMemoryManager", () => { expect(legacyCollections.has("memory-dir-main")).toBe(true); expect(legacyCollections.has("memory-root")).toBe(false); expect(legacyCollections.has("memory-dir")).toBe(false); + expect(legacyCollections.has("memory-alt-main")).toBe(false); + expect(legacyCollections.has("memory-alt")).toBe(false); }); it("rebinds conflicting collection name when path+pattern slot is already occupied", async () => { @@ -876,6 +878,87 @@ describe("QmdMemoryManager", () => { expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("rebinding")); }); + it("rebinds legacy memory-alt when it still owns the root slot for MEMORY.md", async () => { + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + const listedCollections = new Map< + string, + { + path: string; + pattern: string; + } + >([["memory-alt", { path: workspaceDir, pattern: "memory.md" }]]); + const removeCalls: string[] = []; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + JSON.stringify( + [...listedCollections.entries()].map(([name, info]) => ({ + name, + path: info.path, + mask: info.pattern, + })), + ), + ); + return child; + } + if (args[0] === "collection" && args[1] === "remove") { + const child = createMockChild({ autoClose: false }); + const name = args[2] ?? ""; + removeCalls.push(name); + listedCollections.delete(name); + queueMicrotask(() => child.closeWith(0)); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + const pathArg = args[2] ?? ""; + const name = args[args.indexOf("--name") + 1] ?? ""; + const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? ""; + const hasConflict = [...listedCollections.entries()].some(([existingName, info]) => { + if (existingName === name || info.path !== pathArg) { + return false; + } + const isRootPatternPair = + (info.pattern === "MEMORY.md" || info.pattern === "memory.md") && + (pattern === "MEMORY.md" || pattern === "memory.md"); + return info.pattern === pattern || isRootPatternPair; + }); + if (hasConflict) { + emitAndClose(child, "stderr", "A collection already exists for this path and pattern", 1); + return child; + } + listedCollections.set(name, { path: pathArg, pattern }); + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(removeCalls).toContain("memory-alt"); + expect(listedCollections.has("memory-root-main")).toBe(true); + expect(listedCollections.has("memory-alt")).toBe(false); + expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("rebinding")); + }); + it("warns instead of silently succeeding when add conflict metadata is unavailable", async () => { cfg = { ...cfg, @@ -912,6 +995,48 @@ describe("QmdMemoryManager", () => { ); }); + it("falls back to --mask when qmd collection add rejects --glob", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + const addFlagCalls: string[] = []; + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + const flag = args.includes("--glob") ? "--glob" : args.includes("--mask") ? "--mask" : ""; + addFlagCalls.push(flag); + if (flag === "--glob") { + emitAndClose(child, "stderr", "unknown flag: --glob", 1); + return child; + } + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(addFlagCalls).toEqual(["--glob", "--mask", "--mask"]); + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining("retrying with legacy compatibility flag"), + ); + }); it("migrates unscoped legacy collections from plain-text collection list output", async () => { cfg = { ...cfg, @@ -1845,6 +1970,46 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("does not query phantom memory-alt collections when MEMORY.md exists", async () => { + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "# canonical root"); + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: true, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [], + }, + }, + } as OpenClawConfig; + + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "search") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stdout", "[]"); + return child; + } + return createMockChild(); + }); + + const { manager, resolved } = await createManager(); + + await manager.search("test", { sessionKey: "agent:main:slack:dm:u123" }); + const maxResults = resolved.qmd?.limits.maxResults; + if (!maxResults) { + throw new Error("qmd maxResults missing"); + } + const searchCalls = spawnMock.mock.calls + .map((call: unknown[]) => call[1] as string[]) + .filter((args: string[]) => args[0] === "search"); + expect(searchCalls).toEqual([ + ["search", "test", "--json", "-n", String(maxResults), "-c", "memory-root-main"], + ["search", "test", "--json", "-n", String(maxResults), "-c", "memory-dir-main"], + ]); + await manager.close(); + }); + it("uses explicit external custom collection names verbatim at query time", async () => { const sharedMirrorDir = path.join(tmpRoot, "shared-notion-mirror"); await fs.mkdir(sharedMirrorDir); diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 47add225ff7..ee4cbca9bf4 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -507,7 +508,13 @@ export class QmdMemoryManager implements MemorySearchManager { if (!this.pathsMatch(details.path, collection.path)) { continue; } - if (details.pattern !== collection.pattern) { + if ( + !this.patternsMatchForManagedCollection( + collection.path, + details.pattern, + collection.pattern, + ) + ) { continue; } return name; @@ -621,7 +628,14 @@ export class QmdMemoryManager implements MemorySearchManager { if (listedLegacy.path && !this.pathsMatch(listedLegacy.path, collection.path)) { return false; } - if (typeof listedLegacy.pattern === "string" && listedLegacy.pattern !== collection.pattern) { + if ( + typeof listedLegacy.pattern === "string" && + !this.patternsMatchForManagedCollection( + collection.path, + listedLegacy.pattern, + collection.pattern, + ) + ) { return false; } return true; @@ -787,10 +801,10 @@ export class QmdMemoryManager implements MemorySearchManager { } private shouldRebindCollection(collection: ManagedCollection, listed: ListedCollection): boolean { - if (typeof listed.pattern === "string" && listed.pattern !== collection.pattern) { - return true; - } if (!listed.path) { + if (typeof listed.pattern === "string" && listed.pattern !== collection.pattern) { + return true; + } // Older qmd versions may only return names from `collection list --json`. // If the pattern is also missing, do not perform destructive rebinds when // metadata is incomplete: remove+add can permanently drop collections if @@ -800,9 +814,63 @@ export class QmdMemoryManager implements MemorySearchManager { if (!this.pathsMatch(listed.path, collection.path)) { return true; } + if ( + typeof listed.pattern === "string" && + !this.patternsMatchForManagedCollection(collection.path, listed.pattern, collection.pattern) + ) { + return true; + } return false; } + private patternsMatchForManagedCollection( + collectionPath: string, + leftPattern: string, + rightPattern: string, + ): boolean { + if (leftPattern === rightPattern) { + return true; + } + return this.isEquivalentDefaultMemoryRootPattern(collectionPath, leftPattern, rightPattern); + } + + private isEquivalentDefaultMemoryRootPattern( + collectionPath: string, + leftPattern: string, + rightPattern: string, + ): boolean { + if ( + !this.isDefaultMemoryRootPattern(leftPattern) || + !this.isDefaultMemoryRootPattern(rightPattern) + ) { + return false; + } + try { + let sawCanonical = false; + let sawLegacyFallback = false; + for (const entry of fsSync.readdirSync(collectionPath, { withFileTypes: true })) { + if (entry.isSymbolicLink() || !entry.isFile()) { + continue; + } + if (entry.name === "MEMORY.md") { + sawCanonical = true; + } else if (entry.name === "memory.md") { + sawLegacyFallback = true; + } + } + if (sawCanonical && sawLegacyFallback) { + return false; + } + return sawCanonical || sawLegacyFallback; + } catch { + return false; + } + } + + private isDefaultMemoryRootPattern(pattern: string): boolean { + return pattern === "MEMORY.md" || pattern === "memory.md"; + } + private pathsMatch(left: string, right: string): boolean { const normalize = (value: string): string => { const resolved = path.isAbsolute(value) diff --git a/packages/memory-host-sdk/src/host/backend-config.test.ts b/packages/memory-host-sdk/src/host/backend-config.test.ts index 19dc6a87af8..55c38292844 100644 --- a/packages/memory-host-sdk/src/host/backend-config.test.ts +++ b/packages/memory-host-sdk/src/host/backend-config.test.ts @@ -1,7 +1,9 @@ +import syncFs from "node:fs"; +import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { resolveAgentWorkspaceDir } from "../../../../src/agents/agent-scope.js"; import type { OpenClawConfig } from "../../../../src/config/config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js"; @@ -28,7 +30,7 @@ describe("resolveMemoryBackendConfig", () => { } as OpenClawConfig; const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); expect(resolved.backend).toBe("qmd"); - expect(resolved.qmd?.collections.length).toBeGreaterThanOrEqual(3); + expect(resolved.qmd?.collections.length).toBe(2); expect(resolved.qmd?.command).toBe("qmd"); expect(resolved.qmd?.searchMode).toBe("search"); expect(resolved.qmd?.update.intervalMs).toBeGreaterThan(0); @@ -38,8 +40,91 @@ describe("resolveMemoryBackendConfig", () => { expect(resolved.qmd?.update.embedTimeoutMs).toBe(120_000); const names = new Set((resolved.qmd?.collections ?? []).map((collection) => collection.name)); expect(names.has("memory-root-main")).toBe(true); - expect(names.has("memory-alt-main")).toBe(true); expect(names.has("memory-dir-main")).toBe(true); + expect(names.has("memory-alt-main")).toBe(false); + const rootCollection = resolved.qmd?.collections.find( + (collection) => collection.name === "memory-root-main", + ); + expect(rootCollection?.pattern).toBe("MEMORY.md"); + }); + + it("uses lowercase memory.md as the root fallback when MEMORY.md is absent", () => { + const workspaceDir = "/workspace/root"; + const legacyEntry = { + name: "memory.md", + isFile: () => true, + isSymbolicLink: () => false, + } as Dirent; + const readdirSpy = vi + .spyOn(syncFs, "readdirSync") + .mockReturnValue([legacyEntry] as unknown as ReturnType); + try { + const cfg = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true, workspace: workspaceDir }], + }, + memory: { + backend: "qmd", + qmd: {}, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const rootCollection = resolved.qmd?.collections.find( + (collection) => collection.name === "memory-root-main", + ); + expect(rootCollection?.pattern).toBe("memory.md"); + expect( + (resolved.qmd?.collections ?? []).some( + (collection) => collection.name === "memory-alt-main", + ), + ).toBe(false); + } finally { + readdirSpy.mockRestore(); + } + }); + + it("prefers MEMORY.md over legacy memory.md when both root files exist", () => { + const workspaceDir = "/workspace/root"; + const entries = [ + { + name: "MEMORY.md", + isFile: () => true, + isSymbolicLink: () => false, + }, + { + name: "memory.md", + isFile: () => true, + isSymbolicLink: () => false, + }, + ] as Dirent[]; + const readdirSpy = vi + .spyOn(syncFs, "readdirSync") + .mockReturnValue(entries as unknown as ReturnType); + try { + const cfg = { + agents: { + defaults: { workspace: workspaceDir }, + list: [{ id: "main", default: true, workspace: workspaceDir }], + }, + memory: { + backend: "qmd", + qmd: {}, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + const rootCollection = resolved.qmd?.collections.find( + (collection) => collection.name === "memory-root-main", + ); + expect(rootCollection?.pattern).toBe("MEMORY.md"); + expect( + (resolved.qmd?.collections ?? []).some( + (collection) => collection.name === "memory-alt-main", + ), + ).toBe(false); + } finally { + readdirSpy.mockRestore(); + } }); it("parses quoted qmd command paths", () => { diff --git a/packages/memory-host-sdk/src/host/backend-config.ts b/packages/memory-host-sdk/src/host/backend-config.ts index 8d5b2804880..54c503955bf 100644 --- a/packages/memory-host-sdk/src/host/backend-config.ts +++ b/packages/memory-host-sdk/src/host/backend-config.ts @@ -318,6 +318,34 @@ function resolveMcporterConfig(raw?: MemoryQmdMcporterConfig): ResolvedQmdMcport return parsed; } +function isRegularDefaultMemoryEntry( + entry: Pick, + expectedName: string, +): boolean { + return entry.name === expectedName && entry.isFile() && !entry.isSymbolicLink(); +} + +function findDefaultMemoryRootPattern(workspaceDir: string): string | null { + try { + let sawLegacyFallback = false; + for (const entry of fs.readdirSync(workspaceDir, { withFileTypes: true })) { + if (isRegularDefaultMemoryEntry(entry, "MEMORY.md")) { + return "MEMORY.md"; + } + if (isRegularDefaultMemoryEntry(entry, "memory.md")) { + sawLegacyFallback = true; + } + } + return sawLegacyFallback ? "memory.md" : null; + } catch { + return null; + } +} + +function resolveDefaultMemoryRootPattern(workspaceDir: string): string { + return findDefaultMemoryRootPattern(workspaceDir) ?? "MEMORY.md"; +} + function resolveDefaultCollections( include: boolean, workspaceDir: string, @@ -328,8 +356,13 @@ function resolveDefaultCollections( return []; } const entries: Array<{ path: string; pattern: string; base: string }> = [ - { path: workspaceDir, pattern: "MEMORY.md", base: "memory-root" }, - { path: workspaceDir, pattern: "memory.md", base: "memory-alt" }, + // The root memory slot is singular: prefer MEMORY.md, but keep lowercase + // memory.md as a legacy fallback when the canonical file is absent. + { + path: workspaceDir, + pattern: resolveDefaultMemoryRootPattern(workspaceDir), + base: "memory-root", + }, { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, ]; return entries.map((entry) => ({ diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 465a63abc8a..a969aaf0cf8 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -78,6 +78,25 @@ describe("listMemoryFiles", () => { expect(files.some((file) => file.endsWith("standalone.md"))).toBe(true); }); + it("uses lowercase memory.md as the root fallback when MEMORY.md is absent", async () => { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory"); + + const files = await listMemoryFiles(tmpDir); + + expect(files).toEqual([path.join(tmpDir, "memory.md")]); + }); + + it("prefers MEMORY.md when both root files exist", async () => { + const tmpDir = getTmpDir(); + await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); + await fs.writeFile(path.join(tmpDir, "memory.md"), "# Legacy memory"); + + const files = await listMemoryFiles(tmpDir); + + expect(files).toEqual([path.join(tmpDir, "MEMORY.md")]); + }); + it("handles relative paths in additional paths", async () => { const tmpDir = getTmpDir(); await fs.writeFile(path.join(tmpDir, "MEMORY.md"), "# Default memory"); diff --git a/packages/memory-host-sdk/src/host/internal.ts b/packages/memory-host-sdk/src/host/internal.ts index bb64bd1c2c0..22979832ea0 100644 --- a/packages/memory-host-sdk/src/host/internal.ts +++ b/packages/memory-host-sdk/src/host/internal.ts @@ -113,14 +113,33 @@ async function walkDir(dir: string, files: string[], multimodal?: MemoryMultimod } } +async function resolveDefaultMemoryRootFile(workspaceDir: string): Promise { + try { + let legacyFallback: string | null = null; + const entries = await fs.readdir(workspaceDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isSymbolicLink() || !entry.isFile()) { + continue; + } + if (entry.name === "MEMORY.md") { + return path.join(workspaceDir, entry.name); + } + if (entry.name === "memory.md") { + legacyFallback = path.join(workspaceDir, entry.name); + } + } + return legacyFallback; + } catch { + return null; + } +} + export async function listMemoryFiles( workspaceDir: string, extraPaths?: string[], multimodal?: MemoryMultimodalSettings, ): Promise { const result: string[] = []; - const memoryFile = path.join(workspaceDir, "MEMORY.md"); - const altMemoryFile = path.join(workspaceDir, "memory.md"); const memoryDir = path.join(workspaceDir, "memory"); const addMarkdownFile = async (absPath: string) => { @@ -136,8 +155,10 @@ export async function listMemoryFiles( } catch {} }; - await addMarkdownFile(memoryFile); - await addMarkdownFile(altMemoryFile); + const rootMemoryFile = await resolveDefaultMemoryRootFile(workspaceDir); + if (rootMemoryFile) { + await addMarkdownFile(rootMemoryFile); + } try { const dirStat = await fs.lstat(memoryDir); if (!dirStat.isSymbolicLink() && dirStat.isDirectory()) {