feat(memory): harden dreaming and multilingual memory promotion (#60697)

* 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
This commit is contained in:
Vincent Koc
2026-04-04 15:48:13 +09:00
committed by GitHub
parent 0ab160cda9
commit 0609bf8581
19 changed files with 2308 additions and 56 deletions

View File

@@ -2,6 +2,7 @@ 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"));
@@ -10,10 +11,13 @@ const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-defaul
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,
@@ -35,13 +39,41 @@ vi.mock("../agents/model-auth.js", () => ({
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", () => {
@@ -70,8 +102,34 @@ describe("noteMemorySearchHealth", () => {
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 () => {
@@ -369,6 +427,109 @@ describe("noteMemorySearchHealth", () => {
});
});
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";

View File

@@ -9,15 +9,22 @@ import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import {
auditShortTermPromotionArtifacts,
getBuiltinMemoryEmbeddingProviderDoctorMetadata,
listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata,
repairShortTermPromotionArtifacts,
type ShortTermAuditSummary,
} from "../plugin-sdk/memory-core-engine-runtime.js";
import { DEFAULT_LOCAL_MODEL } from "../plugin-sdk/memory-core-host-engine-embeddings.js";
import { checkQmdBinaryAvailability } from "../plugin-sdk/memory-core-host-engine-qmd.js";
import { hasConfiguredMemorySecretInput } from "../plugin-sdk/memory-core-host-secret.js";
import { resolveActiveMemoryBackendConfig } from "../plugins/memory-runtime.js";
import {
getActiveMemorySearchManager,
resolveActiveMemoryBackendConfig,
} from "../plugins/memory-runtime.js";
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
import type { DoctorPrompter } from "./doctor-prompter.js";
function resolveSuggestedRemoteMemoryProvider(): string | undefined {
return listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata().find(
@@ -25,6 +32,146 @@ function resolveSuggestedRemoteMemoryProvider(): string | undefined {
)?.providerId;
}
type RuntimeMemoryAuditContext = {
workspaceDir?: string;
backend?: string;
dbPath?: string;
qmdCollections?: number;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
async function resolveRuntimeMemoryAuditContext(
cfg: OpenClawConfig,
): Promise<RuntimeMemoryAuditContext | null> {
const agentId = resolveDefaultAgentId(cfg);
const result = await getActiveMemorySearchManager({
cfg,
agentId,
purpose: "status",
});
const manager = result.manager;
if (!manager) {
return null;
}
try {
const status = manager.status();
const customQmd = asRecord(asRecord(status.custom)?.qmd);
return {
workspaceDir: status.workspaceDir?.trim(),
backend: status.backend,
dbPath: status.dbPath,
qmdCollections:
typeof customQmd?.collections === "number" ? customQmd.collections : undefined,
};
} finally {
await manager.close?.().catch(() => undefined);
}
}
function buildMemoryRecallIssueNote(audit: ShortTermAuditSummary): string | null {
if (audit.issues.length === 0) {
return null;
}
const issueLines = audit.issues.map((issue) => `- ${issue.message}`);
const hasFixableIssue = audit.issues.some((issue) => issue.fixable);
const guidance = hasFixableIssue
? `Fix: ${formatCliCommand("openclaw doctor --fix")} or ${formatCliCommand("openclaw memory status --fix")}`
: `Verify: ${formatCliCommand("openclaw memory status --deep")}`;
return [
"Memory recall artifacts need attention:",
...issueLines,
`Recall store: ${audit.storePath}`,
guidance,
].join("\n");
}
export async function noteMemoryRecallHealth(cfg: OpenClawConfig): Promise<void> {
try {
const context = await resolveRuntimeMemoryAuditContext(cfg);
const workspaceDir = context?.workspaceDir?.trim();
if (!workspaceDir) {
return;
}
const audit = await auditShortTermPromotionArtifacts({
workspaceDir,
qmd:
context?.backend === "qmd"
? {
dbPath: context.dbPath,
collections: context.qmdCollections,
}
: undefined,
});
const message = buildMemoryRecallIssueNote(audit);
if (message) {
note(message, "Memory search");
}
} catch (err) {
note(
`Memory recall audit could not be completed: ${err instanceof Error ? err.message : String(err)}`,
"Memory search",
);
}
}
export async function maybeRepairMemoryRecallHealth(params: {
cfg: OpenClawConfig;
prompter: DoctorPrompter;
}): Promise<void> {
try {
const context = await resolveRuntimeMemoryAuditContext(params.cfg);
const workspaceDir = context?.workspaceDir?.trim();
if (!workspaceDir) {
return;
}
const audit = await auditShortTermPromotionArtifacts({
workspaceDir,
qmd:
context?.backend === "qmd"
? {
dbPath: context.dbPath,
collections: context.qmdCollections,
}
: undefined,
});
const hasFixableIssue = audit.issues.some((issue) => issue.fixable);
if (!hasFixableIssue) {
return;
}
const approved = await params.prompter.confirmRuntimeRepair({
message: "Normalize memory recall artifacts and remove stale promotion locks?",
initialValue: true,
});
if (!approved) {
return;
}
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (!repair.changed) {
return;
}
const lines = [
"Memory recall artifacts repaired:",
repair.rewroteStore
? `- rewrote recall store${repair.removedInvalidEntries > 0 ? ` (-${repair.removedInvalidEntries} invalid entries)` : ""}`
: null,
repair.removedStaleLock ? "- removed stale promotion lock" : null,
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
].filter(Boolean);
note(lines.join("\n"), "Doctor changes");
} catch (err) {
note(
`Memory recall repair could not be completed: ${err instanceof Error ? err.message : String(err)}`,
"Memory search",
);
}
}
/**
* Check whether memory search has a usable embedding provider.
* Runs as part of `openclaw doctor` — config-only checks where possible;