From 15b86ac6d05f0ae13dbc438d5013996db6246480 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Apr 2026 18:50:17 +0100 Subject: [PATCH] fix: narrow qmd defaults and clawblocker memory --- CHANGELOG.md | 9 ++++ docs/concepts/memory-qmd.md | 9 ++-- docs/reference/memory-config.md | 3 ++ .../src/memory/qmd-manager.test.ts | 42 +++++++++---------- .../host/backend-config.test.ts | 23 +++++++++- src/memory-host-sdk/host/backend-config.ts | 5 ++- 6 files changed, 62 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f1495fdfa2..2ef433555e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ Docs: https://docs.openclaw.ai - Dreaming/narrative: harden transient narrative cleanup by retrying timed-out deletes, scrubbing stale dreaming session artifacts through the lock-aware session-store path, and isolating transient narrative session keys per workspace. (#65320, #61674) - Memory/wiki: preserve Unicode letters, digits, and combining marks in wiki slugs and contradiction clustering, and cap Unicode filename segments to safe byte lengths so non-ASCII titles stop collapsing or overflowing path limits. (#64742) Thanks @zhouhe-xydt. - UI/WebChat: hide synthetic transcript-repair tool results from chat history reloads so internal recovery markers do not leak into visible chat after reconnects. (#65247) Thanks @wangwllu. +- WhatsApp/outbound: fall back to the first `mediaUrls` entry when `mediaUrl` is empty so gateway media sends stop silently dropping attachments that already have a resolved media list. (#64394) Thanks @eric-fr4 and @vincentkoc. +- Doctor/Discord: stop `openclaw doctor --fix` from rewriting legacy Discord preview-streaming config into the nested modern shape, so downgrades can still recover without hand-editing `channels.discord.streaming`. (#65035) Thanks @vincentkoc. +- Gateway/auth: blank the shipped example gateway credential in `.env.example` and fail startup when a copied placeholder token or password is still configured, so operators cannot accidentally launch with a publicly known secret. (#64586) Thanks @navarrotech and @vincentkoc. +- Memory/active-memory+dreaming: keep active-memory recall runs on the strongest resolved channel, consume managed dreaming heartbeat events exactly once, stop dreaming from re-ingesting its own narrative transcripts, and add explicit repair/dedupe recovery flows in CLI, doctor, and the Dreams UI. +- Gateway/keepalive: stop marking WebSocket tick broadcasts as droppable so slow or backpressured clients do not self-disconnect with `tick timeout` while long-running work is still alive. (#65256) Thanks @100yenadmin and @vincentkoc. +- Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys. +- Doctor: warn when on-disk agent directories still exist under `~/.openclaw/agents//agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana. - Telegram: route approval button callback queries onto a separate sequentializer lane so plugin approval clicks can resolve immediately instead of deadlocking behind the blocked agent turn. (#64979) Thanks @nk3750. - Telegram/direct sessions: keep commentary-only assistant fallback payloads out of visible direct delivery, so Codex planning chatter cannot leak into Telegram DMs when a run has no `final_answer` text. - Gateway/keepalive: stop marking WebSocket tick broadcasts as droppable so slow or backpressured clients do not self-disconnect with `tick timeout` while long-running work is still alive. (#65436) @@ -40,6 +47,8 @@ Docs: https://docs.openclaw.ai - Discord/gateway: clear stale heartbeat timers before reconnecting so zombie gateway callbacks cannot crash the process and drop in-flight replies. (#65009) Thanks @SARAMALI15792. - Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys. - Agents/Anthropic replay: preserve immutable signed-thinking replay safety across stored and live reruns, keep non-thinking embedded `tool_result` user blocks intact, and drop conflicting preserved tool IDs before validation so retries stop degrading into omitted tool calls. (#65126) Thanks @shakkernerd. +- Memory/QMD: allow channel sessions in the shipped default QMD scope, while still denying groups. +- Memory/QMD: stop registering the legacy lowercase root memory file as a separate default collection, so QMD now prefers `MEMORY.md` and the `memory/` tree without duplicate collection-add warnings. ## 2026.4.11 diff --git a/docs/concepts/memory-qmd.md b/docs/concepts/memory-qmd.md index 82a67ed2cc2..df680281402 100644 --- a/docs/concepts/memory-qmd.md +++ b/docs/concepts/memory-qmd.md @@ -51,6 +51,9 @@ legacy `--mask` collection flags and older MCP tool names when needed. - OpenClaw creates collections from your workspace memory files and any configured `memory.qmd.paths`, then runs `qmd update` + `qmd embed` on boot and periodically (default every 5 minutes). +- The default workspace collection tracks `MEMORY.md` plus the `memory/` + tree. Lowercase `memory.md` remains a bootstrap fallback, not a separate QMD + collection. - Boot refresh runs in the background so chat startup is not blocked. - Searches use the configured `searchMode` (default: `search`; also supports `vsearch` and `query`). If a mode fails, OpenClaw retries with `qmd query`. @@ -114,8 +117,8 @@ collection under `~/.openclaw/agents//qmd/sessions/`. ## Search scope -By default, QMD search results are only surfaced in DM sessions (not groups or -channels). Configure `memory.qmd.scope` to change this: +By default, QMD search results are surfaced in direct and channel sessions +(not groups). Configure `memory.qmd.scope` to change this: ```json5 { @@ -164,7 +167,7 @@ with `qmd query "test"` using the same XDG dirs OpenClaw uses. Set to `120000` for slower hardware. **Empty results in group chats?** Check `memory.qmd.scope` -- the default only -allows DM sessions. +allows direct and channel sessions. **Workspace-visible temp repos causing `ENAMETOOLONG` or broken indexing?** QMD traversal currently follows the underlying QMD scanner behavior rather than diff --git a/docs/reference/memory-config.md b/docs/reference/memory-config.md index 4d2234e685f..d7e47bfb890 100644 --- a/docs/reference/memory-config.md +++ b/docs/reference/memory-config.md @@ -438,6 +438,9 @@ Controls which sessions can receive QMD search results. Same schema as } ``` +The shipped default allows direct and channel sessions, while still denying +groups. + Default is DM-only. `match.keyPrefix` matches the normalized session key; `match.rawKeyPrefix` matches the raw key including `agent::`. diff --git a/extensions/memory-core/src/memory/qmd-manager.test.ts b/extensions/memory-core/src/memory/qmd-manager.test.ts index 4cdae40971f..06331692d47 100644 --- a/extensions/memory-core/src/memory/qmd-manager.test.ts +++ b/extensions/memory-core/src/memory/qmd-manager.test.ts @@ -29,17 +29,17 @@ const MEMORY_EMBEDDING_PROVIDERS_KEY = Symbol.for("openclaw.memoryEmbeddingProvi const MCPORTER_STATE_KEY = Symbol.for("openclaw.mcporterState"); const QMD_EMBED_QUEUE_KEY = Symbol.for("openclaw.qmdEmbedQueueTail"); -type MockChild = EventEmitter & { +interface MockChild extends EventEmitter { stdout: EventEmitter; stderr: EventEmitter; kill: (signal?: NodeJS.Signals) => void; closeWith: (code?: number | null) => void; -}; +} function createMockChild(params?: { autoClose?: boolean; closeDelayMs?: number }): MockChild { const stdout = new EventEmitter(); const stderr = new EventEmitter(); - const child = new EventEmitter() as MockChild; + const child = new EventEmitter(); child.stdout = stdout; child.stderr = stderr; child.closeWith = (code = 0) => { @@ -134,7 +134,7 @@ import { QmdMemoryManager } from "./qmd-manager.js"; const spawnMock = mockedSpawn as unknown as Mock; const originalPath = process.env.PATH; const originalPathExt = process.env.PATHEXT; -const originalWindowsPath = (process.env as NodeJS.ProcessEnv & { Path?: string }).Path; +const originalWindowsPath = process.env.Path; describe("QmdMemoryManager", () => { let fixtureRoot: string; @@ -269,9 +269,9 @@ describe("QmdMemoryManager", () => { process.env.PATHEXT = originalPathExt; } if (originalWindowsPath === undefined) { - delete (process.env as NodeJS.ProcessEnv & { Path?: string }).Path; + delete process.env.Path; } else { - (process.env as NodeJS.ProcessEnv & { Path?: string }).Path = originalWindowsPath; + process.env.Path = originalWindowsPath; } delete (globalThis as Record)[MCPORTER_STATE_KEY]; delete (globalThis as Record)[QMD_EMBED_QUEUE_KEY]; @@ -423,7 +423,9 @@ describe("QmdMemoryManager", () => { const { manager } = await createManager({ mode: "full" }); expect(watchMock).toHaveBeenCalledTimes(1); - const watcher = watchMock.mock.results[0]?.value as EventEmitter & { close: Mock }; + const watcher = watchMock.mock.results[0]?.value as { + emit: (event: string, ...args: unknown[]) => boolean; + }; const initialUpdateCalls = spawnMock.mock.calls.filter((call) => call[1]?.[0] === "update"); expect(initialUpdateCalls).toHaveLength(0); @@ -735,7 +737,6 @@ describe("QmdMemoryManager", () => { } >([ ["memory-root", { path: workspaceDir, pattern: "MEMORY.md" }], - ["memory-alt", { path: workspaceDir, pattern: "memory.md" }], ["memory-dir", { path: path.join(workspaceDir, "memory"), pattern: "**/*.md" }], ]); const removeCalls: string[] = []; @@ -790,12 +791,10 @@ describe("QmdMemoryManager", () => { const { manager } = await createManager({ mode: "full" }); await manager.close(); - expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]); + expect(removeCalls).toEqual(["memory-root", "memory-dir"]); expect(legacyCollections.has("memory-root-main")).toBe(true); - expect(legacyCollections.has("memory-alt-main")).toBe(true); expect(legacyCollections.has("memory-dir-main")).toBe(true); expect(legacyCollections.has("memory-root")).toBe(false); - expect(legacyCollections.has("memory-alt")).toBe(false); expect(legacyCollections.has("memory-dir")).toBe(false); }); @@ -934,14 +933,11 @@ describe("QmdMemoryManager", () => { child, "stdout", [ - "Collections (3):", + "Collections (2):", "", "memory-root (qmd://memory-root/)", " Pattern: MEMORY.md", "", - "memory-alt (qmd://memory-alt/)", - " Pattern: memory.md", - "", "memory-dir (qmd://memory-dir/)", " Pattern: **/*.md", "", @@ -967,8 +963,8 @@ describe("QmdMemoryManager", () => { const { manager } = await createManager({ mode: "full" }); await manager.close(); - expect(removeCalls).toEqual(["memory-root", "memory-alt", "memory-dir"]); - expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(removeCalls).toEqual(["memory-root", "memory-dir"]); + expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); }); it("does not migrate unscoped collections when listed metadata differs", async () => { @@ -1104,8 +1100,8 @@ describe("QmdMemoryManager", () => { .map((args) => args[args.indexOf("--name") + 1]); expect(updateCalls).toBe(2); - expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); - expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]); + expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); expect(logWarnMock).toHaveBeenCalledWith( expect.stringContaining("suspected null-byte collection metadata"), ); @@ -1161,8 +1157,8 @@ describe("QmdMemoryManager", () => { .map((args) => args[args.indexOf("--name") + 1]); expect(updateCalls).toBe(2); - expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); - expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]); + expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); expect(logWarnMock).toHaveBeenCalledWith( expect.stringContaining("suspected null-byte collection metadata"), ); @@ -1218,8 +1214,8 @@ describe("QmdMemoryManager", () => { .map((args) => args[args.indexOf("--name") + 1]); expect(updateCalls).toBe(2); - expect(removeCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); - expect(addCalls).toEqual(["memory-root-main", "memory-alt-main", "memory-dir-main"]); + expect(removeCalls).toEqual(["memory-root-main", "memory-dir-main"]); + expect(addCalls).toEqual(["memory-root-main", "memory-dir-main"]); expect(logWarnMock).toHaveBeenCalledWith( expect.stringContaining("duplicate document constraint"), ); diff --git a/src/memory-host-sdk/host/backend-config.test.ts b/src/memory-host-sdk/host/backend-config.test.ts index f0c53f034d0..2322671aeeb 100644 --- a/src/memory-host-sdk/host/backend-config.test.ts +++ b/src/memory-host-sdk/host/backend-config.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveMemoryBackendConfig } from "./backend-config.js"; +import { isQmdScopeAllowed } from "./qmd-scope.js"; const resolveComparablePath = (value: string, workspaceDir = "/workspace/root"): string => path.isAbsolute(value) ? path.resolve(value) : path.resolve(workspaceDir, value); @@ -42,7 +43,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); @@ -52,10 +53,28 @@ 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); }); + it("allows direct and channel sessions in the default qmd scope", () => { + const cfg = { + agents: { defaults: { workspace: "/tmp/memory-test" } }, + memory: { + backend: "qmd", + qmd: {}, + }, + } as OpenClawConfig; + const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" }); + + expect(isQmdScopeAllowed(resolved.qmd?.scope, "agent:main:discord:direct:user-123")).toBe(true); + expect(isQmdScopeAllowed(resolved.qmd?.scope, "agent:main:discord:channel:chan-123")).toBe( + true, + ); + expect(isQmdScopeAllowed(resolved.qmd?.scope, "agent:main:discord:group:group-123")).toBe( + false, + ); + }); + it("parses quoted qmd command paths", () => { const cfg = { agents: { defaults: { workspace: "/tmp/memory-test" } }, diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts index cfcd0c49f21..ae6a2da55d3 100644 --- a/src/memory-host-sdk/host/backend-config.ts +++ b/src/memory-host-sdk/host/backend-config.ts @@ -107,6 +107,10 @@ const DEFAULT_QMD_SCOPE: SessionSendPolicyConfig = { action: "allow", match: { chatType: "direct" }, }, + { + action: "allow", + match: { chatType: "channel" }, + }, ], }; @@ -332,7 +336,6 @@ function resolveDefaultCollections( } 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" }, { path: path.join(workspaceDir, "memory"), pattern: "**/*.md", base: "memory-dir" }, ]; return entries.map((entry) => ({