mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 05:20:22 +00:00
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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user