fix: narrow qmd defaults and clawblocker memory

This commit is contained in:
Peter Steinberger
2026-04-12 18:50:17 +01:00
parent e01d2e7e7a
commit 15b86ac6d0
6 changed files with 62 additions and 29 deletions

View File

@@ -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/<id>/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

View File

@@ -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/<id>/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

View File

@@ -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:<id>:`.

View File

@@ -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<PropertyKey, unknown>)[MCPORTER_STATE_KEY];
delete (globalThis as Record<PropertyKey, unknown>)[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"),
);

View File

@@ -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" } },

View File

@@ -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) => ({