From c4dbcc3444a9eab82053b1e639576f8472034323 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 14 Feb 2026 15:41:16 -0800 Subject: [PATCH] Memory/QMD: make status checks side-effect free --- CHANGELOG.md | 1 + src/cli/memory-cli.ts | 3 ++- src/commands/status.scan.ts | 2 +- src/memory/qmd-manager.test.ts | 20 +++++++++++++++++++ src/memory/qmd-manager.ts | 13 ++++++++++--- src/memory/search-manager.test.ts | 32 ++++++++++++++++++++++++++++++- src/memory/search-manager.ts | 14 +++++++++++--- 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f6ec35dbf..5f168e8102e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: query QMD index using exact docid matches before falling back to prefix lookup for better recall correctness and index efficiency. - Memory/QMD: make QMD result JSON parsing resilient to noisy command output by extracting the first JSON array from noisy `stdout`. - Memory/QMD: treat prefixed `no results found` marker output as an empty result set in qmd JSON parsing. (#11302) Thanks @blazerui. +- Memory/QMD: make `memory status` read-only by skipping QMD boot update/embed side effects for status-only manager checks. - Memory/QMD: pass result limits to `search`/`vsearch` commands so QMD can cap results earlier. - Memory/QMD: avoid reading full markdown files when a `from/lines` window is requested in QMD reads. - Memory/QMD: skip rewriting unchanged session export markdown files during sync to reduce disk churn. diff --git a/src/cli/memory-cli.ts b/src/cli/memory-cli.ts index ab5ff9d979a..b447785a87b 100644 --- a/src/cli/memory-cli.ts +++ b/src/cli/memory-cli.ts @@ -253,8 +253,9 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) { }> = []; for (const agentId of agentIds) { + const managerPurpose = opts.index ? "default" : "status"; await withManager({ - getManager: () => getMemorySearchManager({ cfg, agentId }), + getManager: () => getMemorySearchManager({ cfg, agentId, purpose: managerPurpose }), onMissing: (error) => defaultRuntime.log(error ?? "Memory search disabled."), onCloseError: (err) => defaultRuntime.error(`Memory manager close failed: ${formatErrorMessage(err)}`), diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 64570253a80..862a2c6c151 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -158,7 +158,7 @@ export async function scanStatus( return null; } const agentId = agentStatus.defaultId ?? "main"; - const { manager } = await getMemorySearchManager({ cfg, agentId }); + const { manager } = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); if (!manager) { return null; } diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 93d8681d5ea..2dd5a26e50d 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -189,6 +189,26 @@ describe("QmdMemoryManager", () => { await manager?.close(); }); + it("skips qmd command side effects in status mode initialization", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "5m", debounceMs: 60_000, onBoot: true }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + }, + }, + } as OpenClawConfig; + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved, mode: "status" }); + expect(manager).toBeTruthy(); + expect(spawnMock).not.toHaveBeenCalled(); + await manager?.close(); + }); + it("can be configured to block startup on boot update", async () => { cfg = { ...cfg, diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index d2e9bbca387..8b917e27f58 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -44,18 +44,21 @@ type SessionExporterConfig = { collectionName: string; }; +type QmdManagerMode = "full" | "status"; + export class QmdMemoryManager implements MemorySearchManager { static async create(params: { cfg: OpenClawConfig; agentId: string; resolved: ResolvedMemoryBackendConfig; + mode?: QmdManagerMode; }): Promise { const resolved = params.resolved.qmd; if (!resolved) { return null; } const manager = new QmdMemoryManager({ cfg: params.cfg, agentId: params.agentId, resolved }); - await manager.initialize(); + await manager.initialize(params.mode ?? "full"); return manager; } @@ -143,7 +146,12 @@ export class QmdMemoryManager implements MemorySearchManager { } } - private async initialize(): Promise { + private async initialize(mode: QmdManagerMode): Promise { + this.bootstrapCollections(); + if (mode === "status") { + return; + } + await fs.mkdir(this.xdgConfigHome, { recursive: true }); await fs.mkdir(this.xdgCacheHome, { recursive: true }); await fs.mkdir(path.dirname(this.indexPath), { recursive: true }); @@ -156,7 +164,6 @@ export class QmdMemoryManager implements MemorySearchManager { // isolated while models are shared. await this.symlinkSharedModels(); - this.bootstrapCollections(); await this.ensureCollections(); if (this.qmd.update.onBoot) { diff --git a/src/memory/search-manager.test.ts b/src/memory/search-manager.test.ts index 0b352bff20c..35913363853 100644 --- a/src/memory/search-manager.test.ts +++ b/src/memory/search-manager.test.ts @@ -53,6 +53,8 @@ const fallbackManager = { close: vi.fn(async () => {}), }; +const mockMemoryIndexGet = vi.fn(async () => fallbackManager); + vi.mock("./qmd-manager.js", () => ({ QmdMemoryManager: { create: vi.fn(async () => mockPrimary), @@ -61,7 +63,7 @@ vi.mock("./qmd-manager.js", () => ({ vi.mock("./manager.js", () => ({ MemoryIndexManager: { - get: vi.fn(async () => fallbackManager), + get: mockMemoryIndexGet, }, })); @@ -83,6 +85,8 @@ beforeEach(() => { fallbackManager.probeEmbeddingAvailability.mockClear(); fallbackManager.probeVectorAvailability.mockClear(); fallbackManager.close.mockClear(); + mockMemoryIndexGet.mockReset(); + mockMemoryIndexGet.mockResolvedValue(fallbackManager); QmdMemoryManager.create.mockClear(); }); @@ -126,6 +130,32 @@ describe("getMemorySearchManager caching", () => { expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2); }); + it("does not cache status-only qmd managers", async () => { + const agentId = "status-agent"; + const cfg = { + memory: { backend: "qmd", qmd: {} }, + agents: { list: [{ id: agentId, default: true, workspace: "/tmp/workspace" }] }, + } as const; + + const first = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + const second = await getMemorySearchManager({ cfg, agentId, purpose: "status" }); + + expect(first.manager).toBeTruthy(); + expect(second.manager).toBeTruthy(); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(QmdMemoryManager.create).toHaveBeenCalledTimes(2); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(QmdMemoryManager.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ agentId, mode: "status" }), + ); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(QmdMemoryManager.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ agentId, mode: "status" }), + ); + }); + it("does not evict a newer cached wrapper when closing an older failed wrapper", async () => { const retryAgentId = "retry-agent-close"; const cfg = { diff --git a/src/memory/search-manager.ts b/src/memory/search-manager.ts index aead3417641..5978753e47e 100644 --- a/src/memory/search-manager.ts +++ b/src/memory/search-manager.ts @@ -19,13 +19,17 @@ export type MemorySearchManagerResult = { export async function getMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; + purpose?: "default" | "status"; }): Promise { const resolved = resolveMemoryBackendConfig(params); if (resolved.backend === "qmd" && resolved.qmd) { + const statusOnly = params.purpose === "status"; const cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd); - const cached = QMD_MANAGER_CACHE.get(cacheKey); - if (cached) { - return { manager: cached }; + if (!statusOnly) { + const cached = QMD_MANAGER_CACHE.get(cacheKey); + if (cached) { + return { manager: cached }; + } } try { const { QmdMemoryManager } = await import("./qmd-manager.js"); @@ -33,8 +37,12 @@ export async function getMemorySearchManager(params: { cfg: params.cfg, agentId: params.agentId, resolved, + mode: statusOnly ? "status" : "full", }); if (primary) { + if (statusOnly) { + return { manager: primary }; + } const wrapper = new FallbackMemoryManager( { primary,