diff --git a/CHANGELOG.md b/CHANGELOG.md index 0204e608086..a187816c95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Memory/QMD: recreate stale managed QMD collections when startup repair finds the collection name already exists, so root memory narrows back to `MEMORY.md` instead of staying on broad workspace markdown indexing. - Agents/OpenAI: surface selected-model capacity failures from PI, Codex, and auto-reply harness paths with a model-switch hint instead of the generic empty-response error. Thanks @vincentkoc. - Providers/OpenAI: route `openai/gpt-image-2` through configured Codex OAuth directly when an `openai-codex` profile is active, instead of probing `OPENAI_API_KEY` first. - Providers/OpenAI: stop advertising the removed `gpt-5.3-codex-spark` Codex model through fallback catalogs, and suppress stale rows with a GPT-5.5 recovery hint. diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 643c9d91ae0..3533fb4c516 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -990,6 +990,129 @@ describe("QmdMemoryManager", () => { ); }); + it("recreates a managed collection when list fails but add reports the same name 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; + + const removed: string[] = []; + const added = new Map(); + const addAttempts = new Map(); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose(child, "stderr", "temporary qmd list failure", 1); + return child; + } + if (args[0] === "collection" && args[1] === "remove") { + const child = createMockChild({ autoClose: false }); + const name = args[2] ?? ""; + removed.push(name); + queueMicrotask(() => child.closeWith(0)); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + const name = args[args.indexOf("--name") + 1] ?? ""; + const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? ""; + const attempts = addAttempts.get(name) ?? 0; + addAttempts.set(name, attempts + 1); + if (name === "memory-root-main" && attempts === 0) { + emitAndClose(child, "stderr", "Collection 'memory-root-main' already exists.", 1); + return child; + } + added.set(name, pattern); + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(removed).toContain("memory-root-main"); + expect(added.get("memory-root-main")).toBe("MEMORY.md"); + expect(logWarnMock).toHaveBeenCalledWith( + expect.stringContaining( + "qmd collection add conflict for memory-root-main: collection name already exists", + ), + ); + expect(logWarnMock).not.toHaveBeenCalledWith( + expect.stringContaining("qmd collection add skipped for memory-root-main"), + ); + }); + + it("rebinds memory-root when qmd table output has a stale broad pattern", 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 removed: string[] = []; + const added = new Map(); + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "collection" && args[1] === "list") { + const child = createMockChild({ autoClose: false }); + emitAndClose( + child, + "stdout", + [ + "Collections (2):", + "", + "memory-dir-main (qmd://memory-dir-main/)", + " Pattern: **/*.md", + "", + "memory-root-main (qmd://memory-root-main/)", + " Pattern: **/*.md", + "", + ].join("\n"), + ); + return child; + } + if (args[0] === "collection" && args[1] === "remove") { + const child = createMockChild({ autoClose: false }); + const name = args[2] ?? ""; + removed.push(name); + queueMicrotask(() => child.closeWith(0)); + return child; + } + if (args[0] === "collection" && args[1] === "add") { + const child = createMockChild({ autoClose: false }); + const name = args[args.indexOf("--name") + 1] ?? ""; + const pattern = args[args.indexOf("--glob") + 1] ?? args[args.indexOf("--mask") + 1] ?? ""; + added.set(name, pattern); + queueMicrotask(() => child.closeWith(0)); + return child; + } + return createMockChild(); + }); + + const { manager } = await createManager({ mode: "full" }); + await manager.close(); + + expect(removed).toContain("memory-root-main"); + expect(added.get("memory-root-main")).toBe("MEMORY.md"); + expect(removed).not.toContain("memory-dir-main"); + }); + it("falls back to --mask when qmd collection add rejects --glob", async () => { cfg = { ...cfg, diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index 7b5de1612e4..5d4efc00a3c 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -509,12 +509,22 @@ export class QmdMemoryManager implements MemorySearchManager { } catch (err) { const message = formatErrorMessage(err); if (this.isCollectionAlreadyExistsError(message)) { - const rebound = await this.tryRebindConflictingCollection({ - collection, - existing, - addErrorMessage: message, - }); - if (!rebound) { + const rebound = + (await this.tryRebindSameNameCollection({ + collection, + addErrorMessage: message, + })) || + (await this.tryRebindConflictingCollection({ + collection, + existing, + addErrorMessage: message, + })); + if (rebound) { + existing.set(collection.name, { + path: collection.path, + pattern: collection.pattern, + }); + } else { log.warn(`qmd collection add skipped for ${collection.name}: ${message}`); } continue; @@ -524,6 +534,49 @@ export class QmdMemoryManager implements MemorySearchManager { } } + private async tryRebindSameNameCollection(params: { + collection: ManagedCollection; + addErrorMessage: string; + }): Promise { + const { collection, addErrorMessage } = params; + if (!this.isSameNameCollectionAlreadyExistsError(collection.name, addErrorMessage)) { + return false; + } + log.warn( + `qmd collection add conflict for ${collection.name}: collection name already exists; recreating managed collection`, + ); + try { + await this.removeCollection(collection.name); + } catch (removeErr) { + const removeMessage = formatErrorMessage(removeErr); + if (!this.isCollectionMissingError(removeMessage)) { + log.warn(`qmd collection remove failed for ${collection.name}: ${removeMessage}`); + return false; + } + } + + try { + await this.ensureCollectionPath(collection); + await this.addCollection(collection.path, collection.name, collection.pattern); + return true; + } catch (retryErr) { + const retryMessage = formatErrorMessage(retryErr); + log.warn( + `qmd collection add failed for ${collection.name} after recreating same-name collection: ${retryMessage} (initial: ${addErrorMessage})`, + ); + return false; + } + } + + private isSameNameCollectionAlreadyExistsError(name: string, message: string): boolean { + const lowerName = normalizeLowercaseStringOrEmpty(name); + const lowerMessage = normalizeLowercaseStringOrEmpty(message); + return ( + lowerMessage.includes(`collection '${lowerName}' already exists`) || + lowerMessage.includes(`collection "${lowerName}" already exists`) + ); + } + private async listCollectionsBestEffort(): Promise> { const existing = new Map(); try {