From d4f3ea0fdc3aed561f80f141f59192d815cb95f1 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 7 Feb 2026 19:42:34 -0800 Subject: [PATCH] Memory/QMD: warn when scope denies search --- CHANGELOG.md | 1 + docs/concepts/memory.md | 2 ++ src/memory/qmd-manager.test.ts | 57 ++++++++++++++++++++++++++++++++++ src/memory/qmd-manager.ts | 10 ++++++ 4 files changed, 70 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dddd50f10..8cb5ec9058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6. - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. - Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, and retry QMD after fallback failures. (#9690, #9705) +- Memory/QMD: log explicit warnings when `memory.qmd.scope` blocks a search request. (#10191) - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. - Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras. diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 646575fd8f..f9b3dc9b83 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -184,6 +184,8 @@ out to QMD for retrieval. Key points: - `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session). Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD hits in groups/channels. +- When `scope` denies a search, OpenClaw logs a warning with the derived + `channel`/`chatType` so empty results are easier to debug. - Snippets sourced outside the workspace show up as `qmd//` in `memory_search` results; `memory_get` understands that prefix and reads from the configured QMD collection root. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index 54fb5bd174..38ab9768da 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -4,6 +4,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const { logWarnMock, logDebugMock, logInfoMock } = vi.hoisted(() => ({ + logWarnMock: vi.fn(), + logDebugMock: vi.fn(), + logInfoMock: vi.fn(), +})); + type MockChild = EventEmitter & { stdout: EventEmitter; stderr: EventEmitter; @@ -38,6 +44,18 @@ function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number } return child; } +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => { + const logger = { + warn: logWarnMock, + debug: logDebugMock, + info: logInfoMock, + child: () => logger, + }; + return logger; + }, +})); + vi.mock("node:child_process", () => ({ spawn: vi.fn() })); import { spawn as mockedSpawn } from "node:child_process"; @@ -57,6 +75,9 @@ describe("QmdMemoryManager", () => { beforeEach(async () => { spawnMock.mockReset(); spawnMock.mockImplementation(() => createMockChild()); + logWarnMock.mockReset(); + logDebugMock.mockReset(); + logInfoMock.mockReset(); tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "qmd-manager-test-")); workspaceDir = path.join(tmpRoot, "workspace"); await fs.mkdir(workspaceDir, { recursive: true }); @@ -459,6 +480,42 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + it("logs when qmd scope denies search", async () => { + cfg = { + ...cfg, + memory: { + backend: "qmd", + qmd: { + includeDefaultMemory: false, + update: { interval: "0s", debounceMs: 60_000, onBoot: false }, + paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }], + scope: { + default: "deny", + rules: [{ action: "allow", match: { chatType: "direct" } }], + }, + }, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + logWarnMock.mockClear(); + const beforeCalls = spawnMock.mock.calls.length; + await expect( + manager.search("blocked", { sessionKey: "agent:main:discord:channel:c123" }), + ).resolves.toEqual([]); + + expect(spawnMock.mock.calls.length).toBe(beforeCalls); + expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("qmd search denied by scope")); + expect(logWarnMock).toHaveBeenCalledWith(expect.stringContaining("chatType=channel")); + + await manager.close(); + }); + it("blocks non-markdown or symlink reads for qmd paths", async () => { const resolved = resolveMemoryBackendConfig({ cfg, agentId }); const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 66ef31a01d..e543c48770 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -242,6 +242,7 @@ export class QmdMemoryManager implements MemorySearchManager { opts?: { maxResults?: number; minScore?: number; sessionKey?: string }, ): Promise { if (!this.isScopeAllowed(opts?.sessionKey)) { + this.logScopeDenied(opts?.sessionKey); return []; } const trimmed = query.trim(); @@ -693,6 +694,15 @@ export class QmdMemoryManager implements MemorySearchManager { return fallback === "allow"; } + private logScopeDenied(sessionKey?: string): void { + const channel = this.deriveChannelFromKey(sessionKey) ?? "unknown"; + const chatType = this.deriveChatTypeFromKey(sessionKey) ?? "unknown"; + const key = sessionKey?.trim() || ""; + log.warn( + `qmd search denied by scope (channel=${channel}, chatType=${chatType}, session=${key})`, + ); + } + private deriveChannelFromKey(key?: string) { if (!key) { return undefined;