Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/commands/doctor-memory-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default"));
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
const resolveMemoryBackendConfig = vi.hoisted(() => vi.fn());
const checkQmdBinaryAvailable = vi.hoisted(() => vi.fn());

vi.mock("../terminal/note.js", () => ({
note,
Expand All @@ -28,6 +29,7 @@ vi.mock("../agents/model-auth.js", () => ({

vi.mock("../memory/backend-config.js", () => ({
resolveMemoryBackendConfig,
checkQmdBinaryAvailable,
}));

import { noteMemorySearchHealth } from "./doctor-memory-search.js";
Expand Down Expand Up @@ -58,6 +60,7 @@ describe("noteMemorySearchHealth", () => {
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
resolveMemoryBackendConfig.mockReset();
resolveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
checkQmdBinaryAvailable.mockReset();
});

it("does not warn when local provider is set with no explicit modelPath (default model fallback)", async () => {
Expand Down Expand Up @@ -115,22 +118,48 @@ describe("noteMemorySearchHealth", () => {
expect(note).not.toHaveBeenCalled();
});

it("does not warn when QMD backend is active", async () => {
it("does not warn when QMD backend is active and binary is available", async () => {
resolveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
citations: "auto",
qmd: { command: "qmd" },
});
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
checkQmdBinaryAvailable.mockResolvedValue({ available: true, path: "qmd" });

await noteMemorySearchHealth(cfg, {});

expect(note).not.toHaveBeenCalled();
});

it("warns when QMD backend is active but binary is not available", async () => {
resolveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
citations: "auto",
qmd: { command: "qmd" },
});
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
remote: {},
});
checkQmdBinaryAvailable.mockResolvedValue({
available: false,
error: 'QMD binary "qmd" not found on PATH',
});

await noteMemorySearchHealth(cfg, {});

expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("QMD binary was not found");
expect(message).toContain("memory.backend builtin");
});

it("does not warn when remote apiKey is configured for explicit provider", async () => {
await expectNoWarningWithConfiguredRemoteApiKey("openai");
});
Expand Down
26 changes: 24 additions & 2 deletions src/commands/doctor-memory-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { resolveMemorySearchConfig } from "../agents/memory-search.js";
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveMemoryBackendConfig } from "../memory/backend-config.js";
import {
checkQmdBinaryAvailable,
resolveMemoryBackendConfig,
} from "../memory/backend-config.js";
import { DEFAULT_LOCAL_MODEL } from "../memory/embeddings.js";
import { hasConfiguredMemorySecretInput } from "../memory/secret-input.js";
import { note } from "../terminal/note.js";
Expand Down Expand Up @@ -35,9 +38,28 @@ export async function noteMemorySearchHealth(
}

// QMD backend handles embeddings internally (e.g. embeddinggemma) — no
// separate embedding provider is needed. Skip the provider check entirely.
// separate embedding provider is needed. But we should check if qmd binary is available.
const backendConfig = resolveMemoryBackendConfig({ cfg, agentId });
if (backendConfig.backend === "qmd") {
const qmdCommand = backendConfig.qmd?.command ?? "qmd";
// Use agent workspace as cwd to ensure relative paths (e.g., ./bin/qmd) resolve correctly
const checkResult = await checkQmdBinaryAvailable(qmdCommand, 5000, agentDir);
if (!checkResult.available) {
note(
[
`Memory backend is set to "qmd" but the QMD binary was not found.`,
`Error: ${checkResult.error}`,
"",
"Fix (pick one):",
`- Install QMD: https://github.com/openclaw/qmd#installation`,
`- Check your memory.qmd.command configuration`,
`- Switch to builtin backend: ${formatCliCommand("openclaw config set memory.backend builtin")}`,
"",
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
].join("\n"),
"Memory search",
);
}
return;
}

Expand Down
43 changes: 42 additions & 1 deletion src/memory/backend-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import path from "node:path";
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 { checkQmdBinaryAvailable, resolveMemoryBackendConfig } from "./backend-config.js";

describe("resolveMemoryBackendConfig", () => {
it("defaults to builtin backend when config missing", () => {
Expand Down Expand Up @@ -144,3 +144,44 @@ describe("resolveMemoryBackendConfig", () => {
expect(resolved.qmd?.searchMode).toBe("vsearch");
});
});

describe("checkQmdBinaryAvailable", () => {
it("returns available=false when qmd binary is not found", async () => {
const result = await checkQmdBinaryAvailable("nonexistent-qmd-binary-12345");
expect(result.available).toBe(false);
expect(result.error).toContain("not found");
});

it("returns available=true when qmd binary is available", async () => {
// Mock the execFile to simulate a successful binary check
// We can't rely on system commands having --version
const result = await checkQmdBinaryAvailable("node");
// node may or may not be available in test environment
// Just verify the function returns a valid result structure
expect(result).toHaveProperty("available");
if (result.available) {
expect(result.path).toBeDefined();
} else {
expect(result.error).toBeDefined();
}
});

it("respects custom timeout", async () => {
// Use a command that will hang (sleep on unix, timeout on windows)
const slowCommand = process.platform === "win32" ? "timeout" : "sleep";
const startTime = Date.now();
const result = await checkQmdBinaryAvailable(slowCommand, 100);
const elapsed = Date.now() - startTime;
// Should timeout quickly (within 500ms)
expect(elapsed).toBeLessThan(500);
});

it("handles Windows .cmd extension", async () => {
// On Windows, npm should be available as npm.cmd
if (process.platform === "win32") {
const result = await checkQmdBinaryAvailable("npm");
// npm may or may not be available, but it shouldn't crash
expect(result).toHaveProperty("available");
}
});
});
59 changes: 59 additions & 0 deletions src/memory/backend-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { execFile } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import type { OpenClawConfig } from "../config/config.js";
Expand All @@ -14,6 +16,8 @@ import type {
import { resolveUserPath } from "../utils.js";
import { splitShellArgs } from "../utils/shell-argv.js";

const execFileAsync = promisify(execFile);

export type ResolvedMemoryBackendConfig = {
backend: MemoryBackend;
citations: MemoryCitationsMode;
Expand Down Expand Up @@ -352,3 +356,58 @@ export function resolveMemoryBackendConfig(params: {
qmd: resolved,
};
}

/**
* Check if the QMD binary is available on the system PATH.
* Returns the resolved command path if available, null otherwise.
*
* @param command - The command to check (default: "qmd")
* @param timeoutMs - Timeout in milliseconds (default: 5000)
* @param cwd - Working directory for the command (default: undefined, uses process.cwd())
* @returns Object indicating availability and path or error message
*/
export async function checkQmdBinaryAvailable(
command = "qmd",
timeoutMs = 5000,
cwd?: string,
): Promise<{ available: true; path: string } | { available: false; error: string }> {
const isWindows = process.platform === "win32";
// Only append .cmd for known shim names (qmd, npm, npx) to avoid breaking
// custom executables that rely on PATHEXT resolution (e.g., my-qmd-wrapper -> my-qmd-wrapper.exe)
const knownShims = ["qmd", "npm", "npx"];
const resolvedCommand =
isWindows && !path.extname(command) && knownShims.includes(command)
? `${command}.cmd`
: command;

try {
// Try to run `qmd --version` to verify the binary works
// Use the provided cwd to ensure relative paths are resolved correctly
await execFileAsync(resolvedCommand, ["--version"], {
timeout: timeoutMs,
encoding: "utf8",
cwd,
});
return { available: true, path: resolvedCommand };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
// Check for various "not found" error patterns across platforms
const notFoundPatterns = [
"ENOENT",
"not found",
"cannot find",
"EINVAL", // Windows spawn EINVAL for non-existent executables
];
const isNotFound = notFoundPatterns.some((pattern) => error.includes(pattern));
if (isNotFound) {
return {
available: false,
error: `QMD binary "${command}" not found on PATH. Please install QMD or check your configuration.`,
};
}
return {
available: false,
error: `QMD binary check failed: ${error}`,
};
}
}
102 changes: 59 additions & 43 deletions src/memory/search-manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { ResolvedQmdConfig } from "./backend-config.js";
import { resolveMemoryBackendConfig } from "./backend-config.js";
import { checkQmdBinaryAvailable, resolveMemoryBackendConfig } from "./backend-config.js";
import type {
MemoryEmbeddingProbeResult,
MemorySearchManager,
Expand All @@ -10,12 +11,6 @@

const log = createSubsystemLogger("memory");
const QMD_MANAGER_CACHE = new Map<string, MemorySearchManager>();
let managerRuntimePromise: Promise<typeof import("./manager-runtime.js")> | null = null;

function loadManagerRuntime() {
managerRuntimePromise ??= import("./manager-runtime.js");
return managerRuntimePromise;
}

export type MemorySearchManagerResult = {
manager: MemorySearchManager | null;
Expand All @@ -30,53 +25,59 @@
const resolved = resolveMemoryBackendConfig(params);
if (resolved.backend === "qmd" && resolved.qmd) {
const statusOnly = params.purpose === "status";
let cacheKey: string | undefined;
const cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd);

// Check cache first (fast path) before probing binary
if (!statusOnly) {
cacheKey = buildQmdCacheKey(params.agentId, resolved.qmd);
const cached = QMD_MANAGER_CACHE.get(cacheKey);
if (cached) {
return { manager: cached };
}
}
try {
const { QmdMemoryManager } = await import("./qmd-manager.js");
const primary = await QmdMemoryManager.create({
cfg: params.cfg,
agentId: params.agentId,
resolved,
mode: statusOnly ? "status" : "full",
});
if (primary) {
if (statusOnly) {
return { manager: primary };
}
const wrapper = new FallbackMemoryManager(
{
primary,
fallbackFactory: async () => {
const { MemoryIndexManager } = await loadManagerRuntime();
return await MemoryIndexManager.get(params);

// Check if QMD binary is available before attempting to create manager
// Use agent workspace as cwd to ensure relative paths (e.g., ./bin/qmd) resolve correctly
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
const qmdCheck = await checkQmdBinaryAvailable(resolved.qmd.command, 5000, workspaceDir);
if (!qmdCheck.available) {
log.warn(`QMD binary not available: ${qmdCheck.error}`);
log.warn("Falling back to builtin memory backend. To use QMD, install it and ensure it's on PATH.");
// Skip QMD creation and fall through to builtin backend
} else {
try {
const { QmdMemoryManager } = await import("./qmd-manager.js");
const primary = await QmdMemoryManager.create({
cfg: params.cfg,
agentId: params.agentId,
resolved,
mode: statusOnly ? "status" : "full",
});
if (primary) {
if (statusOnly) {
return { manager: primary };
}
const wrapper = new FallbackMemoryManager(
{
primary,
fallbackFactory: async () => {
const { MemoryIndexManager } = await import("./manager.js");
return await MemoryIndexManager.get(params);
},
},
},
() => {
if (cacheKey) {
QMD_MANAGER_CACHE.delete(cacheKey);
}
},
);
if (cacheKey) {
() => QMD_MANAGER_CACHE.delete(cacheKey),
);
QMD_MANAGER_CACHE.set(cacheKey, wrapper);
return { manager: wrapper };
}
return { manager: wrapper };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd memory unavailable; falling back to builtin: ${message}`);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd memory unavailable; falling back to builtin: ${message}`);
}
}

try {
const { MemoryIndexManager } = await loadManagerRuntime();
const { MemoryIndexManager } = await import("./manager.js");
const manager = await MemoryIndexManager.get(params);
return { manager };
} catch (err) {
Expand All @@ -95,8 +96,8 @@
private readonly deps: {
primary: MemorySearchManager;
fallbackFactory: () => Promise<MemorySearchManager | null>;
},

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / build-artifacts

Cannot find name 'managerRuntimePromise'.

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > closes builtin index managers on teardown after runtime is loaded

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > closes cached managers on global teardown

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > keeps original qmd error when fallback manager initialization fails

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > falls back to builtin search when qmd fails with sqlite busy

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > does not evict a newer cached wrapper when closing an older failed wrapper

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > does not cache status-only qmd managers

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > evicts failed qmd wrapper so next call retries qmd

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks (bun, test, pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts)

src/memory/search-manager.test.ts > getMemorySearchManager caching > reuses the same QMD manager instance for repeated calls

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > closes builtin index managers on teardown after runtime is loaded

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > closes cached managers on global teardown

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > keeps original qmd error when fallback manager initialization fails

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > falls back to builtin search when qmd fails with sqlite busy

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > does not evict a newer cached wrapper when closing an older failed wrapper

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > does not cache status-only qmd managers

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > evicts failed qmd wrapper so next call retries qmd

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9

Check failure on line 99 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / checks-windows (node, test, 2, 6, pnpm test)

src/memory/search-manager.test.ts > getMemorySearchManager caching > reuses the same QMD manager instance for repeated calls

ReferenceError: managerRuntimePromise is not defined ❯ Module.closeAllMemorySearchManagers src/memory/search-manager.ts:99:3 ❯ src/memory/search-manager.test.ts:124:9
private readonly onClose?: () => void,

Check failure on line 100 in src/memory/search-manager.ts

View workflow job for this annotation

GitHub Actions / build-artifacts

Cannot find name 'loadManagerRuntime'.
) {}

async search(
Expand Down Expand Up @@ -230,7 +231,22 @@
}

function buildQmdCacheKey(agentId: string, config: ResolvedQmdConfig): string {
// ResolvedQmdConfig is assembled in a stable field order in resolveMemoryBackendConfig.
// Fast stringify avoids deep key-sorting overhead on this hot path.
return `${agentId}:${JSON.stringify(config)}`;
return `${agentId}:${stableSerialize(config)}`;
}

function stableSerialize(value: unknown): string {
return JSON.stringify(sortValue(value));
}

function sortValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((entry) => sortValue(entry));
}
if (value && typeof value === "object") {
const sortedEntries = Object.keys(value as Record<string, unknown>)
.toSorted((a, b) => a.localeCompare(b))
.map((key) => [key, sortValue((value as Record<string, unknown>)[key])]);
return Object.fromEntries(sortedEntries);
}
return value;
}
Loading