mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:50:42 +00:00
fix: narrow qmd defaults and clawblocker memory
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>:`.
|
||||
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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" } },
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user