mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 18:00:23 +00:00
fix(memory-core): align dreaming promotion
This commit is contained in:
@@ -6,6 +6,12 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
const loadConfig = vi.hoisted(() => vi.fn(() => ({}) as OpenClawConfig));
|
||||
const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "main"));
|
||||
const resolveAgentWorkspaceDir = vi.hoisted(() =>
|
||||
vi.fn((_cfg: OpenClawConfig, _agentId: string) => "/tmp/openclaw"),
|
||||
);
|
||||
const resolveMemorySearchConfig = vi.hoisted(() =>
|
||||
vi.fn((_cfg: OpenClawConfig, _agentId: string) => ({ enabled: true })),
|
||||
);
|
||||
const getMemorySearchManager = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
@@ -14,6 +20,11 @@ vi.mock("../../config/config.js", () => ({
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId,
|
||||
resolveAgentWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/memory-search.js", () => ({
|
||||
resolveMemorySearchConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/memory-runtime.js", () => ({
|
||||
@@ -63,6 +74,8 @@ describe("doctor.memory.status", () => {
|
||||
beforeEach(() => {
|
||||
loadConfig.mockClear();
|
||||
resolveDefaultAgentId.mockClear();
|
||||
resolveAgentWorkspaceDir.mockReset().mockReturnValue("/tmp/openclaw");
|
||||
resolveMemorySearchConfig.mockReset().mockReturnValue({ enabled: true });
|
||||
getMemorySearchManager.mockReset();
|
||||
});
|
||||
|
||||
@@ -134,18 +147,34 @@ describe("doctor.memory.status", () => {
|
||||
});
|
||||
|
||||
it("includes dreaming counts and managed cron status when workspace data is available", async () => {
|
||||
const now = Date.now();
|
||||
const todayIso = new Date(now).toISOString();
|
||||
const earlierIso = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
|
||||
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
const now = Date.parse("2026-04-05T00:30:00.000Z");
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
const recentIso = "2026-04-04T23:45:00.000Z";
|
||||
const olderIso = "2026-04-02T10:00:00.000Z";
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-status-"));
|
||||
const mainWorkspaceDir = path.join(workspaceRoot, "main");
|
||||
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
|
||||
const mainStorePath = path.join(
|
||||
mainWorkspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"short-term-recall.json",
|
||||
);
|
||||
const alphaStorePath = path.join(
|
||||
alphaWorkspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"short-term-recall.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(mainStorePath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
mainStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: todayIso,
|
||||
updatedAt: recentIso,
|
||||
entries: {
|
||||
"memory:memory/2026-04-03.md:1:2": {
|
||||
path: "memory/2026-04-03.md",
|
||||
@@ -155,16 +184,31 @@ describe("doctor.memory.status", () => {
|
||||
"memory:memory/2026-04-02.md:1:2": {
|
||||
path: "memory/2026-04-02.md",
|
||||
source: "memory",
|
||||
promotedAt: todayIso,
|
||||
promotedAt: recentIso,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
alphaStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: recentIso,
|
||||
entries: {
|
||||
"memory:memory/2026-04-01.md:1:2": {
|
||||
path: "memory/2026-04-01.md",
|
||||
source: "memory",
|
||||
promotedAt: earlierIso,
|
||||
promotedAt: olderIso,
|
||||
},
|
||||
"memory:MEMORY.md:1:2": {
|
||||
path: "MEMORY.md",
|
||||
"memory:memory/2026-04-04.md:1:2": {
|
||||
path: "memory/2026-04-04.md",
|
||||
source: "memory",
|
||||
promotedAt: recentIso,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -175,24 +219,42 @@ describe("doctor.memory.status", () => {
|
||||
);
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
userTimezone: "America/Los_Angeles",
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspaceDir },
|
||||
{ id: "alpha", workspace: alphaWorkspaceDir },
|
||||
],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "rem",
|
||||
frequency: "0 */4 * * *",
|
||||
cron: "0 */4 * * *",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
resolveAgentWorkspaceDir.mockImplementation((cfg: OpenClawConfig, agentId: string) => {
|
||||
if (agentId === "alpha") {
|
||||
return alphaWorkspaceDir;
|
||||
}
|
||||
return mainWorkspaceDir;
|
||||
});
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir }),
|
||||
status: () => ({ provider: "gemini", workspaceDir: mainWorkspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
@@ -224,9 +286,10 @@ describe("doctor.memory.status", () => {
|
||||
mode: "rem",
|
||||
enabled: true,
|
||||
frequency: "0 */4 * * *",
|
||||
timezone: "America/Los_Angeles",
|
||||
shortTermCount: 1,
|
||||
promotedTotal: 2,
|
||||
promotedToday: 1,
|
||||
promotedTotal: 3,
|
||||
promotedToday: 2,
|
||||
managedCronPresent: true,
|
||||
nextRunAtMs: now + 60_000,
|
||||
}),
|
||||
@@ -234,8 +297,180 @@ describe("doctor.memory.status", () => {
|
||||
undefined,
|
||||
);
|
||||
expect(close).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the manager workspace when no configured dreaming workspaces resolve", async () => {
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-fallback-"));
|
||||
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {
|
||||
"memory:memory/2026-04-03.md:1:2": {
|
||||
path: "memory/2026-04-03.md",
|
||||
source: "memory",
|
||||
promotedAt: "2026-04-04T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
resolveMemorySearchConfig.mockReturnValue(null);
|
||||
loadConfig.mockReturnValue({
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "core",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
});
|
||||
const respond = vi.fn();
|
||||
|
||||
try {
|
||||
await invokeDoctorMemoryStatus(respond);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
dreaming: expect.objectContaining({
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 1,
|
||||
managedCronPresent: false,
|
||||
storePath,
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges workspace store errors when multiple workspace stores are unreadable", async () => {
|
||||
const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "doctor-memory-error-"));
|
||||
const mainWorkspaceDir = path.join(workspaceRoot, "main");
|
||||
const alphaWorkspaceDir = path.join(workspaceRoot, "alpha");
|
||||
const alphaStorePath = path.join(
|
||||
alphaWorkspaceDir,
|
||||
"memory",
|
||||
".dreams",
|
||||
"short-term-recall.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(alphaStorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
alphaStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.mkdir(path.join(mainWorkspaceDir, "memory", ".dreams"), { recursive: true });
|
||||
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{ id: "main", workspace: mainWorkspaceDir },
|
||||
{ id: "alpha", workspace: alphaWorkspaceDir },
|
||||
],
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"memory-core": {
|
||||
config: {
|
||||
dreaming: {
|
||||
mode: "core",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
resolveAgentWorkspaceDir.mockImplementation((_cfg: OpenClawConfig, agentId: string) =>
|
||||
agentId === "alpha" ? alphaWorkspaceDir : mainWorkspaceDir,
|
||||
);
|
||||
|
||||
const readFileSpy = vi.spyOn(fs, "readFile").mockImplementation(async (target, options) => {
|
||||
const targetPath =
|
||||
typeof target === "string"
|
||||
? target
|
||||
: Buffer.isBuffer(target)
|
||||
? target.toString("utf-8")
|
||||
: target instanceof URL
|
||||
? target.pathname
|
||||
: "";
|
||||
if (
|
||||
targetPath === path.join(mainWorkspaceDir, "memory", ".dreams", "short-term-recall.json") ||
|
||||
targetPath === alphaStorePath
|
||||
) {
|
||||
const error = Object.assign(new Error("denied"), { code: "EACCES" });
|
||||
throw error;
|
||||
}
|
||||
return await vi
|
||||
.importActual<typeof import("node:fs/promises")>("node:fs/promises")
|
||||
.then((actual) => actual.readFile(target, options as never));
|
||||
});
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
getMemorySearchManager.mockResolvedValue({
|
||||
manager: {
|
||||
status: () => ({ provider: "gemini", workspaceDir: mainWorkspaceDir }),
|
||||
probeEmbeddingAvailability: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close,
|
||||
},
|
||||
});
|
||||
const respond = vi.fn();
|
||||
|
||||
try {
|
||||
await invokeDoctorMemoryStatus(respond);
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({
|
||||
dreaming: expect.objectContaining({
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
storeError: "2 dreaming stores had read errors.",
|
||||
}),
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
readFileSpy.mockRestore();
|
||||
await fs.rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,55 +2,25 @@ import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
isSameMemoryDreamingDay,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
type MemoryDreamingMode,
|
||||
} from "../../memory-host-sdk/dreaming.js";
|
||||
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
|
||||
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
const SHORT_TERM_BASENAME_RE = /^(\d{4})-(\d{2})-(\d{2})\.md$/;
|
||||
const MANAGED_DREAMING_CRON_NAME = "Memory Dreaming Promotion";
|
||||
const MANAGED_DREAMING_CRON_TAG = "[managed-by=memory-core.short-term-promotion]";
|
||||
const DREAMING_SYSTEM_EVENT_TEXT = "__openclaw_memory_core_short_term_promotion_dream__";
|
||||
|
||||
type DreamingMode = "off" | "core" | "rem" | "deep";
|
||||
type DreamingPreset = Exclude<DreamingMode, "off">;
|
||||
|
||||
const DREAMING_PRESET_DEFAULTS: Record<
|
||||
DreamingPreset,
|
||||
{
|
||||
frequency: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
}
|
||||
> = {
|
||||
core: {
|
||||
frequency: "0 3 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.75,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 2,
|
||||
},
|
||||
deep: {
|
||||
frequency: "0 */12 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.8,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
rem: {
|
||||
frequency: "0 */6 * * *",
|
||||
limit: 10,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
type DoctorMemoryDreamingPayload = {
|
||||
mode: DreamingMode;
|
||||
mode: MemoryDreamingMode;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
timezone?: string;
|
||||
@@ -93,39 +63,8 @@ function normalizeTrimmedString(value: unknown): string | undefined {
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeDreamingMode(value: unknown): DreamingMode {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "rem" ||
|
||||
normalized === "deep"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const floored = Math.floor(value);
|
||||
return floored < 0 ? fallback : floored;
|
||||
}
|
||||
|
||||
function normalizeScore(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
if (value < 0 || value > 1) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveDreamingConfig(
|
||||
cfg: Record<string, unknown>,
|
||||
cfg: OpenClawConfig,
|
||||
): Omit<
|
||||
DoctorMemoryDreamingPayload,
|
||||
| "shortTermCount"
|
||||
@@ -137,27 +76,19 @@ function resolveDreamingConfig(
|
||||
| "managedCronPresent"
|
||||
| "storeError"
|
||||
> {
|
||||
const plugins = asRecord(cfg.plugins);
|
||||
const entries = asRecord(plugins?.entries);
|
||||
const memoryCore = asRecord(entries?.["memory-core"]);
|
||||
const pluginConfig = asRecord(memoryCore?.config);
|
||||
const dreaming = asRecord(pluginConfig?.dreaming);
|
||||
const mode = normalizeDreamingMode(dreaming?.mode);
|
||||
const preset: DreamingPreset = mode === "off" ? "core" : mode;
|
||||
const defaults = DREAMING_PRESET_DEFAULTS[preset];
|
||||
|
||||
const resolved = resolveMemoryDreamingConfig({
|
||||
pluginConfig: resolveMemoryCorePluginConfig(cfg),
|
||||
cfg,
|
||||
});
|
||||
return {
|
||||
mode,
|
||||
enabled: mode !== "off",
|
||||
frequency: normalizeTrimmedString(dreaming?.frequency) ?? defaults.frequency,
|
||||
timezone: normalizeTrimmedString(dreaming?.timezone),
|
||||
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
|
||||
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
|
||||
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
|
||||
minUniqueQueries: normalizeNonNegativeInt(
|
||||
dreaming?.minUniqueQueries,
|
||||
defaults.minUniqueQueries,
|
||||
),
|
||||
mode: resolved.mode,
|
||||
enabled: resolved.enabled,
|
||||
frequency: resolved.cron,
|
||||
...(resolved.timezone ? { timezone: resolved.timezone } : {}),
|
||||
limit: resolved.limit,
|
||||
minScore: resolved.minScore,
|
||||
minRecallCount: resolved.minRecallCount,
|
||||
minUniqueQueries: resolved.minUniqueQueries,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,20 +98,10 @@ function normalizeMemoryPath(rawPath: string): string {
|
||||
|
||||
function isShortTermMemoryPath(filePath: string): boolean {
|
||||
const normalized = normalizeMemoryPath(filePath);
|
||||
if (SHORT_TERM_PATH_RE.test(normalized)) {
|
||||
if (/(?:^|\/)memory\/(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return SHORT_TERM_BASENAME_RE.test(normalized);
|
||||
}
|
||||
|
||||
function isSameLocalDay(firstEpochMs: number, secondEpochMs: number): boolean {
|
||||
const first = new Date(firstEpochMs);
|
||||
const second = new Date(secondEpochMs);
|
||||
return (
|
||||
first.getFullYear() === second.getFullYear() &&
|
||||
first.getMonth() === second.getMonth() &&
|
||||
first.getDate() === second.getDate()
|
||||
);
|
||||
return /^(\d{4})-(\d{2})-(\d{2})\.md$/.test(normalized);
|
||||
}
|
||||
|
||||
type DreamingStoreStats = Pick<
|
||||
@@ -196,6 +117,7 @@ type DreamingStoreStats = Pick<
|
||||
async function loadDreamingStoreStats(
|
||||
workspaceDir: string,
|
||||
nowMs: number,
|
||||
timezone?: string,
|
||||
): Promise<DreamingStoreStats> {
|
||||
const storePath = path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
|
||||
try {
|
||||
@@ -226,7 +148,7 @@ async function loadDreamingStoreStats(
|
||||
}
|
||||
promotedTotal += 1;
|
||||
const promotedAtMs = Date.parse(promotedAt);
|
||||
if (Number.isFinite(promotedAtMs) && isSameLocalDay(promotedAtMs, nowMs)) {
|
||||
if (Number.isFinite(promotedAtMs) && isSameMemoryDreamingDay(promotedAtMs, nowMs, timezone)) {
|
||||
promotedToday += 1;
|
||||
}
|
||||
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
|
||||
@@ -262,6 +184,46 @@ async function loadDreamingStoreStats(
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDreamingStoreStats(stats: DreamingStoreStats[]): DreamingStoreStats {
|
||||
let shortTermCount = 0;
|
||||
let promotedTotal = 0;
|
||||
let promotedToday = 0;
|
||||
let latestPromotedAtMs = Number.NEGATIVE_INFINITY;
|
||||
let lastPromotedAt: string | undefined;
|
||||
const storePaths = new Set<string>();
|
||||
const storeErrors: string[] = [];
|
||||
|
||||
for (const stat of stats) {
|
||||
shortTermCount += stat.shortTermCount;
|
||||
promotedTotal += stat.promotedTotal;
|
||||
promotedToday += stat.promotedToday;
|
||||
if (stat.storePath) {
|
||||
storePaths.add(stat.storePath);
|
||||
}
|
||||
if (stat.storeError) {
|
||||
storeErrors.push(stat.storeError);
|
||||
}
|
||||
const promotedAtMs = stat.lastPromotedAt ? Date.parse(stat.lastPromotedAt) : Number.NaN;
|
||||
if (Number.isFinite(promotedAtMs) && promotedAtMs > latestPromotedAtMs) {
|
||||
latestPromotedAtMs = promotedAtMs;
|
||||
lastPromotedAt = stat.lastPromotedAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
shortTermCount,
|
||||
promotedTotal,
|
||||
promotedToday,
|
||||
...(storePaths.size === 1 ? { storePath: [...storePaths][0] } : {}),
|
||||
...(lastPromotedAt ? { lastPromotedAt } : {}),
|
||||
...(storeErrors.length === 1
|
||||
? { storeError: storeErrors[0] }
|
||||
: storeErrors.length > 1
|
||||
? { storeError: `${storeErrors.length} dreaming stores had read errors.` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
type ManagedDreamingCronStatus = {
|
||||
managedCronPresent: boolean;
|
||||
nextRunAtMs?: number;
|
||||
@@ -351,15 +313,27 @@ export const doctorHandlers: GatewayRequestHandlers = {
|
||||
embedding = { ok: false, error: "memory embeddings unavailable" };
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const dreamingConfig = resolveDreamingConfig(cfg as Record<string, unknown>);
|
||||
const dreamingConfig = resolveDreamingConfig(cfg);
|
||||
const workspaceDir = normalizeTrimmedString((status as Record<string, unknown>).workspaceDir);
|
||||
const storeStats = workspaceDir
|
||||
? await loadDreamingStoreStats(workspaceDir, nowMs)
|
||||
: {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
};
|
||||
const configuredWorkspaces = resolveMemoryDreamingWorkspaces(cfg).map(
|
||||
(entry) => entry.workspaceDir,
|
||||
);
|
||||
const allWorkspaces =
|
||||
configuredWorkspaces.length > 0 ? configuredWorkspaces : workspaceDir ? [workspaceDir] : [];
|
||||
const storeStats =
|
||||
allWorkspaces.length > 0
|
||||
? mergeDreamingStoreStats(
|
||||
await Promise.all(
|
||||
allWorkspaces.map((entry) =>
|
||||
loadDreamingStoreStats(entry, nowMs, dreamingConfig.timezone),
|
||||
),
|
||||
),
|
||||
)
|
||||
: {
|
||||
shortTermCount: 0,
|
||||
promotedTotal: 0,
|
||||
promotedToday: 0,
|
||||
};
|
||||
const cronStatus = await resolveManagedDreamingCronStatus(context);
|
||||
const payload: DoctorMemoryStatusPayload = {
|
||||
agentId,
|
||||
|
||||
269
src/memory-host-sdk/dreaming.ts
Normal file
269
src/memory-host-sdk/dreaming.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { resolveMemorySearchConfig } from "../agents/memory-search.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
export const DEFAULT_MEMORY_DREAMING_CRON_EXPR = "0 3 * * *";
|
||||
export const DEFAULT_MEMORY_DREAMING_LIMIT = 10;
|
||||
export const DEFAULT_MEMORY_DREAMING_MIN_SCORE = 0.75;
|
||||
export const DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT = 3;
|
||||
export const DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES = 2;
|
||||
export const DEFAULT_MEMORY_DREAMING_MODE = "off";
|
||||
export const DEFAULT_MEMORY_DREAMING_PRESET = "core";
|
||||
|
||||
export type MemoryDreamingPreset = "core" | "deep" | "rem";
|
||||
export type MemoryDreamingMode = MemoryDreamingPreset | "off";
|
||||
|
||||
export type MemoryDreamingConfig = {
|
||||
mode: MemoryDreamingMode;
|
||||
enabled: boolean;
|
||||
cron: string;
|
||||
timezone?: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
verboseLogging: boolean;
|
||||
};
|
||||
|
||||
export type MemoryDreamingWorkspace = {
|
||||
workspaceDir: string;
|
||||
agentIds: string[];
|
||||
};
|
||||
|
||||
export const MEMORY_DREAMING_PRESET_DEFAULTS: Record<
|
||||
MemoryDreamingPreset,
|
||||
{
|
||||
cron: string;
|
||||
limit: number;
|
||||
minScore: number;
|
||||
minRecallCount: number;
|
||||
minUniqueQueries: number;
|
||||
}
|
||||
> = {
|
||||
core: {
|
||||
cron: DEFAULT_MEMORY_DREAMING_CRON_EXPR,
|
||||
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
minScore: DEFAULT_MEMORY_DREAMING_MIN_SCORE,
|
||||
minRecallCount: DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
|
||||
minUniqueQueries: DEFAULT_MEMORY_DREAMING_MIN_UNIQUE_QUERIES,
|
||||
},
|
||||
deep: {
|
||||
cron: "0 */12 * * *",
|
||||
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
minScore: 0.8,
|
||||
minRecallCount: 3,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
rem: {
|
||||
cron: "0 */6 * * *",
|
||||
limit: DEFAULT_MEMORY_DREAMING_LIMIT,
|
||||
minScore: 0.85,
|
||||
minRecallCount: 4,
|
||||
minUniqueQueries: 3,
|
||||
},
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
return fallback;
|
||||
}
|
||||
const floored = Math.floor(num);
|
||||
if (floored < 0) {
|
||||
return fallback;
|
||||
}
|
||||
return floored;
|
||||
}
|
||||
|
||||
function normalizeScore(value: unknown, fallback: number): number {
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
const num = typeof value === "string" ? Number(value.trim()) : Number(value);
|
||||
if (!Number.isFinite(num) || num < 0 || num > 1) {
|
||||
return fallback;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizePathForComparison(input: string): string {
|
||||
const normalized = path.resolve(input);
|
||||
if (process.platform === "win32") {
|
||||
return normalized.toLowerCase();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function formatLocalIsoDay(epochMs: number): string {
|
||||
const date = new Date(epochMs);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function normalizeMemoryDreamingMode(value: unknown): MemoryDreamingMode {
|
||||
const normalized = normalizeTrimmedString(value)?.toLowerCase();
|
||||
if (
|
||||
normalized === "off" ||
|
||||
normalized === "core" ||
|
||||
normalized === "deep" ||
|
||||
normalized === "rem"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_MEMORY_DREAMING_MODE;
|
||||
}
|
||||
|
||||
export function resolveMemoryCorePluginConfig(
|
||||
cfg: OpenClawConfig | Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const root = asRecord(cfg);
|
||||
const plugins = asRecord(root?.plugins);
|
||||
const entries = asRecord(plugins?.entries);
|
||||
const memoryCore = asRecord(entries?.["memory-core"]);
|
||||
return asRecord(memoryCore?.config) ?? undefined;
|
||||
}
|
||||
|
||||
export function resolveMemoryDreamingConfig(params: {
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
cfg?: OpenClawConfig;
|
||||
}): MemoryDreamingConfig {
|
||||
const dreaming = asRecord(params.pluginConfig?.dreaming);
|
||||
const mode = normalizeMemoryDreamingMode(dreaming?.mode);
|
||||
const enabled = mode !== "off";
|
||||
const preset: MemoryDreamingPreset = mode === "off" ? DEFAULT_MEMORY_DREAMING_PRESET : mode;
|
||||
const defaults = MEMORY_DREAMING_PRESET_DEFAULTS[preset];
|
||||
const timezone =
|
||||
normalizeTrimmedString(dreaming?.timezone) ??
|
||||
normalizeTrimmedString(params.cfg?.agents?.defaults?.userTimezone);
|
||||
return {
|
||||
mode,
|
||||
enabled,
|
||||
cron:
|
||||
normalizeTrimmedString(dreaming?.cron) ??
|
||||
normalizeTrimmedString(dreaming?.frequency) ??
|
||||
defaults.cron,
|
||||
...(timezone ? { timezone } : {}),
|
||||
limit: normalizeNonNegativeInt(dreaming?.limit, defaults.limit),
|
||||
minScore: normalizeScore(dreaming?.minScore, defaults.minScore),
|
||||
minRecallCount: normalizeNonNegativeInt(dreaming?.minRecallCount, defaults.minRecallCount),
|
||||
minUniqueQueries: normalizeNonNegativeInt(
|
||||
dreaming?.minUniqueQueries,
|
||||
defaults.minUniqueQueries,
|
||||
),
|
||||
verboseLogging: normalizeBoolean(dreaming?.verboseLogging, false),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatMemoryDreamingDay(epochMs: number, timezone?: string): string {
|
||||
if (!timezone) {
|
||||
return formatLocalIsoDay(epochMs);
|
||||
}
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date(epochMs));
|
||||
const values = new Map(parts.map((part) => [part.type, part.value]));
|
||||
const year = values.get("year");
|
||||
const month = values.get("month");
|
||||
const day = values.get("day");
|
||||
if (year && month && day) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to host-local day for invalid or unsupported timezones.
|
||||
}
|
||||
return formatLocalIsoDay(epochMs);
|
||||
}
|
||||
|
||||
export function isSameMemoryDreamingDay(
|
||||
firstEpochMs: number,
|
||||
secondEpochMs: number,
|
||||
timezone?: string,
|
||||
): boolean {
|
||||
return (
|
||||
formatMemoryDreamingDay(firstEpochMs, timezone) ===
|
||||
formatMemoryDreamingDay(secondEpochMs, timezone)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveMemoryDreamingWorkspaces(cfg: OpenClawConfig): MemoryDreamingWorkspace[] {
|
||||
const configured = Array.isArray(cfg.agents?.list) ? cfg.agents.list : [];
|
||||
const agentIds: string[] = [];
|
||||
const seenAgents = new Set<string>();
|
||||
for (const entry of configured) {
|
||||
if (!entry || typeof entry !== "object" || typeof entry.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const id = entry.id.trim().toLowerCase();
|
||||
if (!id || seenAgents.has(id)) {
|
||||
continue;
|
||||
}
|
||||
seenAgents.add(id);
|
||||
agentIds.push(id);
|
||||
}
|
||||
if (agentIds.length === 0) {
|
||||
agentIds.push(resolveDefaultAgentId(cfg));
|
||||
}
|
||||
|
||||
const byWorkspace = new Map<string, MemoryDreamingWorkspace>();
|
||||
for (const agentId of agentIds) {
|
||||
if (!resolveMemorySearchConfig(cfg, agentId)) {
|
||||
continue;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId)?.trim();
|
||||
if (!workspaceDir) {
|
||||
continue;
|
||||
}
|
||||
const key = normalizePathForComparison(workspaceDir);
|
||||
const existing = byWorkspace.get(key);
|
||||
if (existing) {
|
||||
existing.agentIds.push(agentId);
|
||||
continue;
|
||||
}
|
||||
byWorkspace.set(key, {
|
||||
workspaceDir,
|
||||
agentIds: [agentId],
|
||||
});
|
||||
}
|
||||
return [...byWorkspace.values()];
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "../memory-host-sdk/status.js";
|
||||
export * from "../memory-host-sdk/dreaming.js";
|
||||
|
||||
@@ -46,6 +46,15 @@ export {
|
||||
withProgress,
|
||||
withProgressTotals,
|
||||
} from "./memory-core-host-runtime-cli.js";
|
||||
export {
|
||||
formatMemoryDreamingDay,
|
||||
isSameMemoryDreamingDay,
|
||||
MEMORY_DREAMING_PRESET_DEFAULTS,
|
||||
normalizeMemoryDreamingMode,
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDreamingConfig,
|
||||
resolveMemoryDreamingWorkspaces,
|
||||
} from "./memory-core-host-status.js";
|
||||
export {
|
||||
listMemoryFiles,
|
||||
normalizeExtraMemoryPaths,
|
||||
|
||||
Reference in New Issue
Block a user