mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-04 18:00:22 +00:00
* feat(memory): add recall audit and doctor repair flow * refactor(memory): rename symbolic scoring and harden dreaming * feat(memory): add multilingual concept vocabulary * docs(changelog): note dreaming memory follow-up * docs(changelog): shorten dreaming follow-up entry * fix(memory): address review follow-ups * chore(skills): tighten security triage trust model * Update CHANGELOG.md
541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
import path from "node:path";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { checkQmdBinaryAvailability as checkQmdBinaryAvailabilityFn } from "../plugin-sdk/memory-core-host-engine-qmd.js";
|
|
import type { DoctorPrompter } from "./doctor-prompter.js";
|
|
|
|
const note = vi.hoisted(() => vi.fn());
|
|
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "agent-default"));
|
|
const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default"));
|
|
const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default/workspace"));
|
|
const resolveMemorySearchConfig = vi.hoisted(() => vi.fn());
|
|
const resolveApiKeyForProvider = vi.hoisted(() => vi.fn());
|
|
const resolveActiveMemoryBackendConfig = vi.hoisted(() => vi.fn());
|
|
const getActiveMemorySearchManager = vi.hoisted(() => vi.fn());
|
|
type CheckQmdBinaryAvailability = typeof checkQmdBinaryAvailabilityFn;
|
|
const checkQmdBinaryAvailability = vi.hoisted(() =>
|
|
vi.fn<CheckQmdBinaryAvailability>(async () => ({ available: true })),
|
|
);
|
|
const auditShortTermPromotionArtifacts = vi.hoisted(() => vi.fn());
|
|
const repairShortTermPromotionArtifacts = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../terminal/note.js", () => ({
|
|
note,
|
|
}));
|
|
|
|
vi.mock("../agents/agent-scope.js", () => ({
|
|
resolveDefaultAgentId,
|
|
resolveAgentDir,
|
|
resolveAgentWorkspaceDir,
|
|
}));
|
|
|
|
vi.mock("../agents/memory-search.js", () => ({
|
|
resolveMemorySearchConfig,
|
|
}));
|
|
|
|
vi.mock("../agents/model-auth.js", () => ({
|
|
resolveApiKeyForProvider,
|
|
}));
|
|
|
|
vi.mock("../plugins/memory-runtime.js", () => ({
|
|
resolveActiveMemoryBackendConfig,
|
|
getActiveMemorySearchManager,
|
|
}));
|
|
|
|
vi.mock("../plugin-sdk/memory-core-host-engine-qmd.js", () => ({
|
|
checkQmdBinaryAvailability,
|
|
}));
|
|
|
|
vi.mock("../plugin-sdk/memory-core-engine-runtime.js", () => ({
|
|
auditShortTermPromotionArtifacts,
|
|
repairShortTermPromotionArtifacts,
|
|
getBuiltinMemoryEmbeddingProviderDoctorMetadata: vi.fn((provider: string) => {
|
|
if (provider === "gemini") {
|
|
return { authProviderId: "google", envVars: ["GEMINI_API_KEY"] };
|
|
}
|
|
if (provider === "mistral") {
|
|
return { authProviderId: "mistral", envVars: ["MISTRAL_API_KEY"] };
|
|
}
|
|
if (provider === "openai") {
|
|
return { authProviderId: "openai", envVars: ["OPENAI_API_KEY"] };
|
|
}
|
|
return null;
|
|
}),
|
|
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata: vi.fn(() => [
|
|
{
|
|
providerId: "openai",
|
|
authProviderId: "openai",
|
|
envVars: ["OPENAI_API_KEY"],
|
|
transport: "remote",
|
|
},
|
|
{ providerId: "local", authProviderId: "local", envVars: [], transport: "local" },
|
|
]),
|
|
}));
|
|
|
|
import { noteMemorySearchHealth } from "./doctor-memory-search.js";
|
|
import { maybeRepairMemoryRecallHealth, noteMemoryRecallHealth } from "./doctor-memory-search.js";
|
|
import { detectLegacyWorkspaceDirs } from "./doctor-workspace.js";
|
|
|
|
describe("noteMemorySearchHealth", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
async function expectNoWarningWithConfiguredRemoteApiKey(provider: string) {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider,
|
|
local: {},
|
|
remote: { apiKey: "from-config" },
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
note.mockClear();
|
|
resolveDefaultAgentId.mockClear();
|
|
resolveAgentDir.mockClear();
|
|
resolveAgentWorkspaceDir.mockClear();
|
|
resolveMemorySearchConfig.mockReset();
|
|
resolveApiKeyForProvider.mockReset();
|
|
resolveApiKeyForProvider.mockRejectedValue(new Error("missing key"));
|
|
resolveActiveMemoryBackendConfig.mockReset();
|
|
resolveActiveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
|
|
getActiveMemorySearchManager.mockReset();
|
|
getActiveMemorySearchManager.mockResolvedValue({
|
|
manager: {
|
|
status: () => ({ workspaceDir: "/tmp/agent-default/workspace", backend: "builtin" }),
|
|
close: vi.fn(async () => {}),
|
|
},
|
|
});
|
|
checkQmdBinaryAvailability.mockReset();
|
|
checkQmdBinaryAvailability.mockResolvedValue({ available: true });
|
|
auditShortTermPromotionArtifacts.mockReset();
|
|
auditShortTermPromotionArtifacts.mockResolvedValue({
|
|
storePath: "/tmp/agent-default/workspace/memory/.dreams/short-term-recall.json",
|
|
lockPath: "/tmp/agent-default/workspace/memory/.dreams/short-term-promotion.lock",
|
|
exists: true,
|
|
entryCount: 1,
|
|
promotedCount: 0,
|
|
spacedEntryCount: 0,
|
|
conceptTaggedEntryCount: 1,
|
|
invalidEntryCount: 0,
|
|
issues: [],
|
|
});
|
|
repairShortTermPromotionArtifacts.mockReset();
|
|
repairShortTermPromotionArtifacts.mockResolvedValue({
|
|
changed: false,
|
|
removedInvalidEntries: 0,
|
|
rewroteStore: false,
|
|
removedStaleLock: false,
|
|
});
|
|
});
|
|
|
|
it("does not warn when local provider is set with no explicit modelPath (default model fallback)", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "local",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("warns when local provider with default model but gateway probe reports not ready", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "local",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {
|
|
gatewayMemoryProbe: { checked: true, ready: false, error: "node-llama-cpp not installed" },
|
|
});
|
|
|
|
expect(note).toHaveBeenCalledTimes(1);
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("gateway reports local embeddings are not ready");
|
|
expect(message).toContain("node-llama-cpp not installed");
|
|
});
|
|
|
|
it("does not warn when local provider with default model and gateway probe is ready", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "local",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {
|
|
gatewayMemoryProbe: { checked: true, ready: true },
|
|
});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not warn when local provider has an explicit hf: modelPath", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "local",
|
|
local: { modelPath: "hf:some-org/some-model-GGUF/model.gguf" },
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not warn when QMD backend is active", async () => {
|
|
resolveActiveMemoryBackendConfig.mockReturnValue({
|
|
backend: "qmd",
|
|
citations: "auto",
|
|
qmd: { command: "qmd" },
|
|
});
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
expect(checkQmdBinaryAvailability).toHaveBeenCalledWith({
|
|
command: "qmd",
|
|
env: process.env,
|
|
cwd: "/tmp/agent-default/workspace",
|
|
});
|
|
});
|
|
|
|
it("warns when QMD backend is active but the qmd binary is unavailable", async () => {
|
|
resolveActiveMemoryBackendConfig.mockReturnValue({
|
|
backend: "qmd",
|
|
citations: "auto",
|
|
qmd: { command: "qmd" },
|
|
});
|
|
checkQmdBinaryAvailability.mockResolvedValueOnce({
|
|
available: false,
|
|
error: "spawn qmd ENOENT",
|
|
});
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).toHaveBeenCalledTimes(1);
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("QMD memory backend is configured");
|
|
expect(message).toContain("spawn qmd ENOENT");
|
|
expect(message).toContain("npm install -g @tobilu/qmd");
|
|
});
|
|
|
|
it("does not warn when remote apiKey is configured for explicit provider", async () => {
|
|
await expectNoWarningWithConfiguredRemoteApiKey("openai");
|
|
});
|
|
|
|
it("treats SecretRef remote apiKey as configured for explicit provider", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "openai",
|
|
local: {},
|
|
remote: {
|
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not warn in auto mode when remote apiKey is configured", async () => {
|
|
await expectNoWarningWithConfiguredRemoteApiKey("auto");
|
|
});
|
|
|
|
it("treats SecretRef remote apiKey as configured in auto mode", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {
|
|
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
|
},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(note).not.toHaveBeenCalled();
|
|
expect(resolveApiKeyForProvider).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves provider auth from the default agent directory", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "gemini",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
resolveApiKeyForProvider.mockResolvedValue({
|
|
apiKey: "k",
|
|
source: "env: GEMINI_API_KEY",
|
|
mode: "api-key",
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {});
|
|
|
|
expect(resolveApiKeyForProvider).toHaveBeenCalledWith({
|
|
provider: "google",
|
|
cfg,
|
|
agentDir: "/tmp/agent-default",
|
|
});
|
|
expect(note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves mistral auth for explicit mistral embedding provider", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "mistral",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
resolveApiKeyForProvider.mockResolvedValue({
|
|
apiKey: "k",
|
|
source: "env: MISTRAL_API_KEY",
|
|
mode: "api-key",
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg);
|
|
|
|
expect(resolveApiKeyForProvider).toHaveBeenCalledWith({
|
|
provider: "mistral",
|
|
cfg,
|
|
agentDir: "/tmp/agent-default",
|
|
});
|
|
expect(note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("notes when gateway probe reports embeddings ready and CLI API key is missing", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "gemini",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {
|
|
gatewayMemoryProbe: { checked: true, ready: true },
|
|
});
|
|
|
|
const message = note.mock.calls[0]?.[0] as string;
|
|
expect(message).toContain("reports memory embeddings are ready");
|
|
});
|
|
|
|
it("uses model configure hint when gateway probe is unavailable and API key is missing", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "gemini",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg, {
|
|
gatewayMemoryProbe: {
|
|
checked: true,
|
|
ready: false,
|
|
error: "gateway memory probe unavailable: timeout",
|
|
},
|
|
});
|
|
|
|
const message = note.mock.calls[0]?.[0] as string;
|
|
expect(message).toContain("Gateway memory probe for default agent is not ready");
|
|
expect(message).toContain("openclaw configure --section model");
|
|
expect(message).not.toContain("openclaw auth add --provider");
|
|
});
|
|
|
|
it("warns in auto mode when no local modelPath and no API keys are configured", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg);
|
|
|
|
// In auto mode, canAutoSelectLocal requires an explicit local file path.
|
|
// DEFAULT_LOCAL_MODEL fallback does NOT apply to auto — only to explicit
|
|
// provider: "local". So with no local file and no API keys, warn.
|
|
expect(note).toHaveBeenCalledTimes(1);
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("needs at least one embedding provider");
|
|
expect(message).toContain("openclaw configure --section model");
|
|
});
|
|
|
|
it("still warns in auto mode when only ollama credentials exist", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
resolveApiKeyForProvider.mockImplementation(async ({ provider }: { provider: string }) => {
|
|
if (provider === "ollama") {
|
|
return {
|
|
apiKey: "ollama-local", // pragma: allowlist secret
|
|
source: "env: OLLAMA_API_KEY",
|
|
mode: "api-key",
|
|
};
|
|
}
|
|
throw new Error("missing key");
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg);
|
|
|
|
expect(note).toHaveBeenCalledTimes(1);
|
|
const providerCalls = resolveApiKeyForProvider.mock.calls as Array<[{ provider: string }]>;
|
|
const providersChecked = providerCalls.map(([arg]) => arg.provider);
|
|
expect(providersChecked).toEqual(["openai", "google", "voyage", "mistral"]);
|
|
});
|
|
|
|
it("uses runtime-derived env var hints for explicit providers", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "gemini",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg);
|
|
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("GEMINI_API_KEY");
|
|
expect(message).toContain('provider is set to "gemini"');
|
|
});
|
|
|
|
it("uses runtime-derived env var hints in auto mode", async () => {
|
|
resolveMemorySearchConfig.mockReturnValue({
|
|
provider: "auto",
|
|
local: {},
|
|
remote: {},
|
|
});
|
|
|
|
await noteMemorySearchHealth(cfg);
|
|
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("OPENAI_API_KEY");
|
|
expect(message).toContain("GEMINI_API_KEY");
|
|
expect(message).toContain("GOOGLE_API_KEY");
|
|
expect(message).toContain("VOYAGE_API_KEY");
|
|
expect(message).toContain("MISTRAL_API_KEY");
|
|
});
|
|
});
|
|
|
|
describe("memory recall doctor integration", () => {
|
|
const cfg = {} as OpenClawConfig;
|
|
|
|
function createPrompter(overrides: Partial<DoctorPrompter> = {}): DoctorPrompter {
|
|
return {
|
|
confirm: vi.fn(async () => true),
|
|
confirmAutoFix: vi.fn(async () => true),
|
|
confirmAggressiveAutoFix: vi.fn(async () => true),
|
|
confirmRuntimeRepair: vi.fn(async () => true),
|
|
select: vi.fn(async (_params, fallback) => fallback),
|
|
shouldRepair: true,
|
|
shouldForce: false,
|
|
repairMode: {
|
|
shouldRepair: true,
|
|
shouldForce: false,
|
|
nonInteractive: false,
|
|
canPrompt: true,
|
|
updateInProgress: false,
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
it("notes recall-store audit problems with doctor guidance", async () => {
|
|
auditShortTermPromotionArtifacts.mockResolvedValueOnce({
|
|
storePath: "/tmp/agent-default/workspace/memory/.dreams/short-term-recall.json",
|
|
lockPath: "/tmp/agent-default/workspace/memory/.dreams/short-term-promotion.lock",
|
|
exists: true,
|
|
entryCount: 12,
|
|
promotedCount: 4,
|
|
spacedEntryCount: 2,
|
|
conceptTaggedEntryCount: 10,
|
|
invalidEntryCount: 1,
|
|
issues: [
|
|
{
|
|
severity: "warn",
|
|
code: "recall-store-invalid",
|
|
message: "Short-term recall store contains 1 invalid entry.",
|
|
fixable: true,
|
|
},
|
|
{
|
|
severity: "warn",
|
|
code: "recall-lock-stale",
|
|
message: "Short-term promotion lock appears stale.",
|
|
fixable: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
await noteMemoryRecallHealth(cfg);
|
|
|
|
expect(auditShortTermPromotionArtifacts).toHaveBeenCalledWith({
|
|
workspaceDir: "/tmp/agent-default/workspace",
|
|
qmd: undefined,
|
|
});
|
|
expect(note).toHaveBeenCalledTimes(1);
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("Memory recall artifacts need attention:");
|
|
expect(message).toContain("doctor --fix");
|
|
expect(message).toContain("memory status --fix");
|
|
});
|
|
|
|
it("runs memory recall repair during doctor --fix", async () => {
|
|
auditShortTermPromotionArtifacts.mockResolvedValueOnce({
|
|
storePath: "/tmp/agent-default/workspace/memory/.dreams/short-term-recall.json",
|
|
lockPath: "/tmp/agent-default/workspace/memory/.dreams/short-term-promotion.lock",
|
|
exists: true,
|
|
entryCount: 12,
|
|
promotedCount: 4,
|
|
spacedEntryCount: 2,
|
|
conceptTaggedEntryCount: 10,
|
|
invalidEntryCount: 1,
|
|
issues: [
|
|
{
|
|
severity: "warn",
|
|
code: "recall-store-invalid",
|
|
message: "Short-term recall store contains 1 invalid entry.",
|
|
fixable: true,
|
|
},
|
|
],
|
|
});
|
|
repairShortTermPromotionArtifacts.mockResolvedValueOnce({
|
|
changed: true,
|
|
removedInvalidEntries: 1,
|
|
rewroteStore: true,
|
|
removedStaleLock: true,
|
|
});
|
|
const prompter = createPrompter();
|
|
|
|
await maybeRepairMemoryRecallHealth({ cfg, prompter });
|
|
|
|
expect(prompter.confirmRuntimeRepair).toHaveBeenCalled();
|
|
expect(repairShortTermPromotionArtifacts).toHaveBeenCalledWith({
|
|
workspaceDir: "/tmp/agent-default/workspace",
|
|
});
|
|
expect(note).toHaveBeenCalledTimes(1);
|
|
const message = String(note.mock.calls[0]?.[0] ?? "");
|
|
expect(message).toContain("Memory recall artifacts repaired:");
|
|
expect(message).toContain("rewrote recall store");
|
|
expect(message).toContain("removed stale promotion lock");
|
|
});
|
|
});
|
|
|
|
describe("detectLegacyWorkspaceDirs", () => {
|
|
it("returns active workspace and no legacy dirs", () => {
|
|
const workspaceDir = "/home/user/openclaw";
|
|
const detection = detectLegacyWorkspaceDirs({ workspaceDir });
|
|
expect(detection.activeWorkspace).toBe(path.resolve(workspaceDir));
|
|
expect(detection.legacyDirs).toEqual([]);
|
|
});
|
|
});
|