feat(memory-core): add dreaming promotion with weighted recall thresholds (#60569)

* memory-core: add dreaming promotion flow with weighted thresholds

* docs(memory): mark dreaming as experimental

* memory-core: address dreaming promotion review feedback

* memory-core: harden short-term promotion concurrency

* acpx: make abort-process test timer-independent

* memory-core: simplify dreaming config with mode presets

* memory-core: add /dreaming command and tighten recall tracking

* ui: add Dreams tab with sleeping lobster animation

Adds a new Dreams tab to the gateway UI under the Agent group.
The tab is gated behind the memory-core dreaming config — it only
appears in the sidebar when dreaming.mode is not 'off'.

Features:
- Sleeping vector lobster with breathing animation
- Floating Z's, twinkling starfield, moon glow
- Rotating dream phrase bubble (17 whimsical phrases)
- Memory stats bar (short-term, long-term, promoted)
- Active/idle visual states
- 14 unit tests

* plugins: fix --json stdout pollution from hook runner log

The hook runner initialization message was using log.info() which
writes to stdout via console.log, breaking JSON.parse() in the
Docker smoke test for 'openclaw plugins list --json'. Downgrade to
log.debug() so it only appears when debugging is enabled.

* ui: keep Dreams tab visible when dreaming is off

* tests: fix contracts and stabilize extension shards

* memory-core: harden dreaming recall persistence and locking

* fix: stabilize dreaming PR gates (#60569) (thanks @vignesh07)

* test: fix rebase drift in telegram and plugin guards
This commit is contained in:
Vignesh
2026-04-03 20:26:53 -07:00
committed by GitHub
parent 2687a49575
commit 4c1022c73b
34 changed files with 3843 additions and 45 deletions

View File

@@ -25,7 +25,17 @@ import {
withProgress,
withProgressTotals,
} from "./cli.host.runtime.js";
import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./cli.types.js";
import type {
MemoryCommandOptions,
MemoryPromoteCommandOptions,
MemorySearchCommandOptions,
} from "./cli.types.js";
import {
applyShortTermPromotions,
recordShortTermRecalls,
rankShortTermPromotionCandidates,
resolveShortTermRecallStorePath,
} from "./short-term-promotion.js";
type MemoryManager = NonNullable<Awaited<ReturnType<typeof getMemorySearchManager>>["manager"]>;
type MemoryManagerPurpose = Parameters<typeof getMemorySearchManager>[0]["purpose"];
@@ -746,6 +756,17 @@ export async function runMemorySearch(
process.exitCode = 1;
return;
}
const workspaceDir =
typeof (manager as { status?: () => { workspaceDir?: string } }).status === "function"
? manager.status().workspaceDir
: undefined;
void recordShortTermRecalls({
workspaceDir,
query,
results,
}).catch(() => {
// Recall tracking is best-effort and must not block normal search results.
});
if (opts.json) {
defaultRuntime.writeJson({ results });
return;
@@ -771,3 +792,127 @@ export async function runMemorySearch(
},
});
}
export async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
const { config: cfg, diagnostics } = await loadMemoryCommandConfig("memory promote");
emitMemorySecretResolveDiagnostics(diagnostics, { json: Boolean(opts.json) });
const agentId = resolveAgent(cfg, opts.agent);
await withMemoryManagerForAgent({
cfg,
agentId,
purpose: "status",
run: async (manager) => {
const status = manager.status();
const workspaceDir = status.workspaceDir?.trim();
if (!workspaceDir) {
defaultRuntime.error("Memory promote requires a resolvable workspace directory.");
process.exitCode = 1;
return;
}
let candidates: Awaited<ReturnType<typeof rankShortTermPromotionCandidates>>;
try {
candidates = await rankShortTermPromotionCandidates({
workspaceDir,
limit: opts.limit,
minScore: opts.minScore,
minRecallCount: opts.minRecallCount,
minUniqueQueries: opts.minUniqueQueries,
includePromoted: Boolean(opts.includePromoted),
});
} catch (err) {
defaultRuntime.error(`Memory promote ranking failed: ${formatErrorMessage(err)}`);
process.exitCode = 1;
return;
}
let applyResult: Awaited<ReturnType<typeof applyShortTermPromotions>> | undefined;
if (opts.apply) {
try {
applyResult = await applyShortTermPromotions({
workspaceDir,
candidates,
limit: opts.limit,
minScore: opts.minScore,
minRecallCount: opts.minRecallCount,
minUniqueQueries: opts.minUniqueQueries,
});
} catch (err) {
defaultRuntime.error(`Memory promote apply failed: ${formatErrorMessage(err)}`);
process.exitCode = 1;
return;
}
}
const storePath = resolveShortTermRecallStorePath(workspaceDir);
if (opts.json) {
defaultRuntime.writeJson({
workspaceDir,
storePath,
candidates,
apply: applyResult
? {
applied: applyResult.applied,
memoryPath: applyResult.memoryPath,
appliedCandidates: applyResult.appliedCandidates,
}
: undefined,
});
return;
}
if (candidates.length === 0) {
defaultRuntime.log("No short-term recall candidates.");
defaultRuntime.log(`Recall store: ${shortenHomePath(storePath)}`);
return;
}
const rich = isRich();
const lines: string[] = [];
lines.push(
`${colorize(rich, theme.heading, "Short-Term Promotion Candidates")} ${colorize(
rich,
theme.muted,
`(${agentId})`,
)}`,
);
lines.push(`${colorize(rich, theme.muted, "Recall store:")} ${shortenHomePath(storePath)}`);
for (const candidate of candidates) {
lines.push(
`${colorize(rich, theme.success, candidate.score.toFixed(3))} ${colorize(
rich,
theme.accent,
`${shortenHomePath(candidate.path)}:${candidate.startLine}-${candidate.endLine}`,
)}`,
);
lines.push(
colorize(
rich,
theme.muted,
`recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} queries=${candidate.uniqueQueries} age=${candidate.ageDays.toFixed(1)}d`,
),
);
if (candidate.snippet) {
lines.push(colorize(rich, theme.muted, candidate.snippet));
}
lines.push("");
}
if (applyResult) {
if (applyResult.applied > 0) {
lines.push(
colorize(
rich,
theme.success,
`Promoted ${applyResult.applied} candidate(s) to ${shortenHomePath(applyResult.memoryPath)}.`,
),
);
} else {
lines.push(colorize(rich, theme.warn, "No candidates met apply criteria."));
}
}
defaultRuntime.log(lines.join("\n").trim());
},
});
}

View File

@@ -9,6 +9,7 @@ import {
spyRuntimeJson,
spyRuntimeLogs,
} from "../../../src/cli/test-runtime-capture.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
const getMemorySearchManager = vi.hoisted(() => vi.fn());
const loadConfig = vi.hoisted(() => vi.fn(() => ({})));
@@ -106,6 +107,25 @@ describe("memory cli", () => {
);
}
async function waitFor<T>(task: () => Promise<T>, timeoutMs: number = 1500): Promise<T> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
return await task();
} catch (error) {
lastError = error;
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
}
if (lastError instanceof Error) {
throw lastError;
}
throw new Error("Timed out waiting for async test condition");
}
async function runMemoryCli(args: string[]) {
const program = new Command();
program.name("test");
@@ -147,6 +167,15 @@ describe("memory cli", () => {
}
}
async function withTempWorkspace(run: (workspaceDir: string) => Promise<void>) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-cli-promote-"));
try {
await run(workspaceDir);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
}
async function expectCloseFailureAfterCommand(params: {
args: string[];
manager: Record<string, unknown>;
@@ -252,6 +281,8 @@ describe("memory cli", () => {
expect(helpText).toContain("Quick search using positional query.");
expect(helpText).toContain('openclaw memory search --query "deployment" --max-results 20');
expect(helpText).toContain("Limit results for focused troubleshooting.");
expect(helpText).toContain("openclaw memory promote --apply");
expect(helpText).toContain("Append top-ranked short-term candidates into MEMORY.md.");
});
it("prints vector error when unavailable", async () => {
@@ -580,4 +611,147 @@ describe("memory cli", () => {
expect(payload.results).toHaveLength(1);
expect(close).toHaveBeenCalled();
});
it("records short-term recall entries from memory search hits", async () => {
await withTempWorkspace(async (workspaceDir) => {
const close = vi.fn(async () => {});
const search = vi.fn(async () => [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.91,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
]);
mockManager({
search,
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
await runMemoryCli(["search", "glacier", "--json"]);
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
const store = JSON.parse(storeRaw) as {
entries?: Record<string, { path: string; recallCount: number }>;
};
const entries = Object.values(store.entries ?? {});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
path: "memory/2026-04-03.md",
recallCount: 1,
});
expect(close).toHaveBeenCalled();
});
});
it("prints no candidates when promote has no short-term recall data", async () => {
await withTempWorkspace(async (workspaceDir) => {
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["promote"]);
expect(log).toHaveBeenCalledWith("No short-term recall candidates.");
expect(close).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
});
});
it("prints promote candidates as json", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "router notes",
results: [
{
path: "memory/2026-04-03.md",
startLine: 4,
endLine: 8,
score: 0.86,
snippet: "Configured VLAN 10 for IoT on router",
source: "memory",
},
],
});
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli([
"promote",
"--json",
"--min-score",
"0",
"--min-recall-count",
"0",
"--min-unique-queries",
"0",
]);
const payload = firstWrittenJsonArg<{ candidates: unknown[] }>(writeJson);
expect(payload).not.toBeNull();
if (!payload) {
throw new Error("expected json payload");
}
expect(Array.isArray(payload.candidates)).toBe(true);
expect(payload.candidates).toHaveLength(1);
expect(close).toHaveBeenCalled();
});
});
it("applies top promote candidates into MEMORY.md", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "network setup",
results: [
{
path: "memory/2026-04-01.md",
startLine: 10,
endLine: 14,
score: 0.91,
snippet: "Gateway host uses local mode and binds loopback port 18789",
source: "memory",
},
],
});
const close = vi.fn(async () => {});
mockManager({
status: () => makeMemoryStatus({ workspaceDir }),
close,
});
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli([
"promote",
"--apply",
"--min-score",
"0",
"--min-recall-count",
"0",
"--min-unique-queries",
"0",
]);
const memoryPath = path.join(workspaceDir, "MEMORY.md");
const memoryText = await fs.readFile(memoryPath, "utf-8");
expect(memoryText).toContain("Promoted From Short-Term Memory");
expect(memoryText).toContain("memory/2026-04-01.md:10-14");
expect(log).toHaveBeenCalledWith(expect.stringContaining("Promoted 1 candidate(s) to"));
expect(close).toHaveBeenCalled();
});
});
});

View File

@@ -4,7 +4,16 @@ import {
formatHelpExamples,
theme,
} from "openclaw/plugin-sdk/memory-core-host-runtime-cli";
import type { MemoryCommandOptions, MemorySearchCommandOptions } from "./cli.types.js";
import type {
MemoryCommandOptions,
MemoryPromoteCommandOptions,
MemorySearchCommandOptions,
} from "./cli.types.js";
import {
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
DEFAULT_PROMOTION_MIN_SCORE,
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
} from "./short-term-promotion.js";
type MemoryCliRuntime = typeof import("./cli.runtime.js");
@@ -30,6 +39,11 @@ async function runMemorySearch(queryArg: string | undefined, opts: MemorySearchC
await runtime.runMemorySearch(queryArg, opts);
}
async function runMemoryPromote(opts: MemoryPromoteCommandOptions) {
const runtime = await loadMemoryCliRuntime();
await runtime.runMemoryPromote(opts);
}
export function registerMemoryCli(program: Command) {
const memory = program
.command("memory")
@@ -46,6 +60,14 @@ export function registerMemoryCli(program: Command) {
'openclaw memory search --query "deployment" --max-results 20',
"Limit results for focused troubleshooting.",
],
[
`openclaw memory promote --limit 10 --min-score ${DEFAULT_PROMOTION_MIN_SCORE}`,
"Review weighted short-term candidates for long-term memory.",
],
[
"openclaw memory promote --apply",
"Append top-ranked short-term candidates into MEMORY.md.",
],
["openclaw memory status --json", "Output machine-readable JSON (good for scripts)."],
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/memory", "docs.openclaw.ai/cli/memory")}\n`,
);
@@ -84,4 +106,31 @@ export function registerMemoryCli(program: Command) {
.action(async (queryArg: string | undefined, opts: MemorySearchCommandOptions) => {
await runMemorySearch(queryArg, opts);
});
memory
.command("promote")
.description("Rank short-term recalls and optionally append top entries to MEMORY.md")
.option("--agent <id>", "Agent id (default: default agent)")
.option("--limit <n>", "Max candidates", (value: string) => Number(value))
.option(
"--min-score <n>",
`Minimum weighted score (default: ${DEFAULT_PROMOTION_MIN_SCORE})`,
(value: string) => Number(value),
)
.option(
"--min-recall-count <n>",
`Minimum recall count (default: ${DEFAULT_PROMOTION_MIN_RECALL_COUNT})`,
(value: string) => Number(value),
)
.option(
"--min-unique-queries <n>",
`Minimum distinct query count (default: ${DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES})`,
(value: string) => Number(value),
)
.option("--apply", "Append selected candidates to MEMORY.md", false)
.option("--include-promoted", "Include already promoted candidates", false)
.option("--json", "Print JSON")
.action(async (opts: MemoryPromoteCommandOptions) => {
await runMemoryPromote(opts);
});
}

View File

@@ -12,3 +12,12 @@ export type MemorySearchCommandOptions = MemoryCommandOptions & {
maxResults?: number;
minScore?: number;
};
export type MemoryPromoteCommandOptions = MemoryCommandOptions & {
limit?: number;
minScore?: number;
minRecallCount?: number;
minUniqueQueries?: number;
apply?: boolean;
includePromoted?: boolean;
};

View File

@@ -0,0 +1,145 @@
import type {
OpenClawPluginCommandDefinition,
PluginCommandContext,
} from "openclaw/plugin-sdk/core";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import { describe, expect, it, vi } from "vitest";
import { registerDreamingCommand } from "./dreaming-command.js";
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 resolveStoredDreaming(config: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(config.plugins?.entries?.["memory-core"]);
const pluginConfig = asRecord(entry?.config);
return asRecord(pluginConfig?.dreaming) ?? {};
}
function createHarness(initialConfig: OpenClawConfig = {}) {
let command: OpenClawPluginCommandDefinition | undefined;
let runtimeConfig: OpenClawConfig = initialConfig;
const runtime = {
config: {
loadConfig: vi.fn(() => runtimeConfig),
writeConfigFile: vi.fn(async (nextConfig: OpenClawConfig) => {
runtimeConfig = nextConfig;
}),
},
} as unknown as OpenClawPluginApi["runtime"];
const api = {
runtime,
registerCommand: vi.fn((definition: OpenClawPluginCommandDefinition) => {
command = definition;
}),
} as unknown as OpenClawPluginApi;
registerDreamingCommand(api);
if (!command) {
throw new Error("memory-core did not register /dreaming");
}
return {
command,
runtime,
getRuntimeConfig: () => runtimeConfig,
};
}
function createCommandContext(args?: string): PluginCommandContext {
return {
channel: "webchat",
isAuthorizedSender: true,
commandBody: args ? `/dreaming ${args}` : "/dreaming",
args,
config: {},
requestConversationBinding: async () => ({ status: "error", message: "unsupported" }),
detachConversationBinding: async () => ({ removed: false }),
getCurrentConversationBinding: async () => null,
};
}
describe("memory-core /dreaming command", () => {
it("registers with an options-aware description", () => {
const { command } = createHarness();
expect(command.name).toBe("dreaming");
expect(command.acceptsArgs).toBe(true);
expect(command.description).toContain("off|core|rem|deep");
});
it("shows mode explanations when invoked without args", async () => {
const { command } = createHarness();
const result = await command.handler(createCommandContext());
expect(result.text).toContain("Usage: /dreaming off|core|rem|deep");
expect(result.text).toContain("Dreaming status:");
expect(result.text).toContain("- off: disable automatic short-term to long-term promotion.");
expect(result.text).toContain("- core: cadence=0 3 * * *;");
expect(result.text).toContain("- rem: cadence=0 */6 * * *;");
expect(result.text).toContain("- deep: cadence=0 */12 * * *;");
});
it("persists mode changes under plugins.entries.memory-core.config.dreaming.mode", async () => {
const { command, runtime, getRuntimeConfig } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
minScore: 0.9,
},
},
},
},
},
});
const result = await command.handler(createCommandContext("rem"));
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
mode: "rem",
minScore: 0.9,
});
expect(result.text).toContain("Dreaming mode set to rem.");
expect(result.text).toContain("minScore=0.9");
});
it("returns status without mutating config", async () => {
const { command, runtime } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
mode: "deep",
timezone: "America/Los_Angeles",
},
},
},
},
},
});
const result = await command.handler(createCommandContext("status"));
expect(result.text).toContain("Dreaming status:");
expect(result.text).toContain("- mode: deep");
expect(result.text).toContain("- cadence: 0 */12 * * * (America/Los_Angeles)");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
it("shows usage for invalid args and does not mutate config", async () => {
const { command, runtime } = createHarness();
const result = await command.handler(createCommandContext("unknown-mode"));
expect(result.text).toContain("Usage: /dreaming off|core|rem|deep");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,168 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
type DreamingMode = "off" | "core" | "rem" | "deep";
const DREAMING_MODE_LIST = [
"off",
"core",
"rem",
"deep",
] as const satisfies readonly DreamingMode[];
const DEFAULT_DREAMING_MODE: DreamingMode = "off";
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 normalizeDreamingMode(value: unknown): DreamingMode | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (
normalized === "off" ||
normalized === "core" ||
normalized === "rem" ||
normalized === "deep"
) {
return normalized;
}
return null;
}
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {};
}
function resolveDreamingModeFromConfig(pluginConfig: Record<string, unknown>): DreamingMode {
const dreaming = asRecord(pluginConfig.dreaming);
return normalizeDreamingMode(dreaming?.mode) ?? DEFAULT_DREAMING_MODE;
}
function updateDreamingModeInConfig(cfg: OpenClawConfig, mode: DreamingMode): OpenClawConfig {
const entries = { ...(cfg.plugins?.entries ?? {}) };
const existingEntry = asRecord(entries["memory-core"]) ?? {};
const existingConfig = asRecord(existingEntry.config) ?? {};
const existingDreaming = asRecord(existingConfig.dreaming) ?? {};
entries["memory-core"] = {
...existingEntry,
config: {
...existingConfig,
dreaming: {
...existingDreaming,
mode,
},
},
};
return {
...cfg,
plugins: {
...cfg.plugins,
entries,
},
};
}
function formatModeGuideLine(mode: DreamingMode): string {
if (mode === "off") {
return "- off: disable automatic short-term to long-term promotion.";
}
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode,
},
},
});
return (
`- ${mode}: cadence=${resolved.cron}; ` +
`minScore=${resolved.minScore}, minRecallCount=${resolved.minRecallCount}, ` +
`minUniqueQueries=${resolved.minUniqueQueries}.`
);
}
function formatModeGuide(): string {
return DREAMING_MODE_LIST.map((mode) => formatModeGuideLine(mode)).join("\n");
}
function formatStatus(cfg: OpenClawConfig): string {
const pluginConfig = resolveMemoryCorePluginConfig(cfg);
const mode = resolveDreamingModeFromConfig(pluginConfig);
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig,
cfg,
});
const cadence = resolved.enabled ? resolved.cron : "disabled";
const timezone = resolved.enabled && resolved.timezone ? ` (${resolved.timezone})` : "";
return [
"Dreaming status:",
`- mode: ${mode}`,
`- cadence: ${cadence}${timezone}`,
`- limit: ${resolved.limit}`,
`- thresholds: minScore=${resolved.minScore}, minRecallCount=${resolved.minRecallCount}, minUniqueQueries=${resolved.minUniqueQueries}`,
].join("\n");
}
function formatUsage(includeStatus: string): string {
return [
"Usage: /dreaming off|core|rem|deep",
"Use /dreaming status for current settings.",
"",
includeStatus,
"",
"Modes:",
formatModeGuide(),
].join("\n");
}
export function registerDreamingCommand(api: OpenClawPluginApi): void {
api.registerCommand({
name: "dreaming",
description: "Configure memory dreaming mode (off|core|rem|deep).",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const firstToken = args.split(/\s+/).filter(Boolean)[0]?.toLowerCase() ?? "";
const currentConfig = api.runtime.config.loadConfig();
if (
!firstToken ||
firstToken === "help" ||
firstToken === "options" ||
firstToken === "modes"
) {
return { text: formatUsage(formatStatus(currentConfig)) };
}
if (firstToken === "status") {
return { text: formatStatus(currentConfig) };
}
const requestedMode = normalizeDreamingMode(firstToken);
if (!requestedMode) {
return { text: formatUsage(formatStatus(currentConfig)) };
}
const nextConfig = updateDreamingModeInConfig(currentConfig, requestedMode);
await api.runtime.config.writeConfigFile(nextConfig);
return {
text: [
`Dreaming mode set to ${requestedMode}.`,
"",
formatStatus(nextConfig),
"",
"Modes:",
formatModeGuide(),
].join("\n"),
};
},
});
}

View File

@@ -0,0 +1,494 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
__testing,
reconcileShortTermDreamingCronJob,
resolveShortTermPromotionDreamingConfig,
runShortTermDreamingPromotionIfTriggered,
} from "./dreaming.js";
import { recordShortTermRecalls } from "./short-term-promotion.js";
const constants = __testing.constants;
type CronParam = NonNullable<Parameters<typeof reconcileShortTermDreamingCronJob>[0]["cron"]>;
type CronJobLike = Awaited<ReturnType<CronParam["list"]>>[number];
type CronAddInput = Parameters<CronParam["add"]>[0];
type CronPatch = Parameters<CronParam["update"]>[1];
function createLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
function createCronHarness(
initialJobs: CronJobLike[] = [],
opts?: { removeResult?: "boolean" | "unknown" },
) {
const jobs: CronJobLike[] = [...initialJobs];
const addCalls: CronAddInput[] = [];
const updateCalls: Array<{ id: string; patch: CronPatch }> = [];
const removeCalls: string[] = [];
const cron: CronParam = {
async list() {
return jobs.map((job) => ({
...job,
...(job.schedule ? { schedule: { ...job.schedule } } : {}),
...(job.payload ? { payload: { ...job.payload } } : {}),
}));
},
async add(input) {
addCalls.push(input);
jobs.push({
id: `job-${jobs.length + 1}`,
name: input.name,
description: input.description,
enabled: input.enabled,
schedule: { ...input.schedule },
sessionTarget: input.sessionTarget,
wakeMode: input.wakeMode,
payload: { ...input.payload },
createdAtMs: Date.now(),
});
return {};
},
async update(id, patch) {
updateCalls.push({ id, patch });
const index = jobs.findIndex((entry) => entry.id === id);
if (index < 0) {
return {};
}
const current = jobs[index]!;
jobs[index] = {
...current,
...(patch.name ? { name: patch.name } : {}),
...(patch.description ? { description: patch.description } : {}),
...(typeof patch.enabled === "boolean" ? { enabled: patch.enabled } : {}),
...(patch.schedule ? { schedule: { ...patch.schedule } } : {}),
...(patch.sessionTarget ? { sessionTarget: patch.sessionTarget } : {}),
...(patch.wakeMode ? { wakeMode: patch.wakeMode } : {}),
...(patch.payload ? { payload: { ...patch.payload } } : {}),
};
return {};
},
async remove(id) {
removeCalls.push(id);
const index = jobs.findIndex((entry) => entry.id === id);
if (index >= 0) {
jobs.splice(index, 1);
}
if (opts?.removeResult === "unknown") {
return {};
}
return { removed: index >= 0 };
},
};
return { cron, jobs, addCalls, updateCalls, removeCalls };
}
describe("short-term dreaming config", () => {
it("uses defaults and user timezone fallback", () => {
const cfg = {
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
} as OpenClawConfig;
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {},
cfg,
});
expect(resolved).toEqual({
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
timezone: "America/Los_Angeles",
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
});
});
it("reads explicit dreaming config values", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "deep",
frequency: "15 2 * * *",
timezone: "UTC",
limit: 7,
minScore: 0.4,
minRecallCount: 2,
minUniqueQueries: 3,
},
},
});
expect(resolved).toEqual({
enabled: true,
cron: "15 2 * * *",
timezone: "UTC",
limit: 7,
minScore: 0.4,
minRecallCount: 2,
minUniqueQueries: 3,
});
});
it("accepts limit=0 as an explicit no-op promotion cap", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
limit: 0,
},
},
});
expect(resolved.limit).toBe(0);
});
it("falls back to defaults when thresholds are negative", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "rem",
minScore: -0.2,
minRecallCount: -2,
minUniqueQueries: -4,
},
},
});
expect(resolved).toMatchObject({
enabled: true,
minScore: constants.DREAMING_PRESET_DEFAULTS.rem.minScore,
minRecallCount: constants.DREAMING_PRESET_DEFAULTS.rem.minRecallCount,
minUniqueQueries: constants.DREAMING_PRESET_DEFAULTS.rem.minUniqueQueries,
});
});
it("keeps dreaming disabled when mode is off", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "off",
},
},
});
expect(resolved.enabled).toBe(false);
});
});
describe("short-term dreaming startup event parsing", () => {
it("resolves cron service from gateway startup event deps", () => {
const harness = createCronHarness();
const resolved = __testing.resolveCronServiceFromStartupEvent({
type: "gateway",
action: "startup",
context: {
deps: {
cron: harness.cron,
},
},
});
expect(resolved).toBe(harness.cron);
});
});
describe("short-term dreaming cron reconciliation", () => {
it("creates a managed cron job when enabled", async () => {
const harness = createCronHarness();
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: true,
cron: "0 1 * * *",
timezone: "UTC",
limit: 8,
minScore: 0.5,
minRecallCount: 4,
minUniqueQueries: 5,
},
logger,
});
expect(result.status).toBe("added");
expect(harness.addCalls).toHaveLength(1);
expect(harness.addCalls[0]).toMatchObject({
name: constants.MANAGED_DREAMING_CRON_NAME,
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: constants.DREAMING_SYSTEM_EVENT_TEXT,
},
schedule: {
kind: "cron",
expr: "0 1 * * *",
tz: "UTC",
},
});
});
it("updates drifted managed jobs and prunes duplicates", async () => {
const desiredConfig = {
enabled: true,
cron: "0 3 * * *",
timezone: "America/Los_Angeles",
limit: 10,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
} as const;
const desired = __testing.buildManagedDreamingCronJob(desiredConfig);
const stalePrimary: CronJobLike = {
id: "job-primary",
name: desired.name,
description: desired.description,
enabled: false,
schedule: { kind: "cron", expr: "0 9 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: "stale-text",
},
createdAtMs: 1,
};
const duplicate: CronJobLike = {
...desired,
id: "job-duplicate",
createdAtMs: 2,
};
const unmanaged: CronJobLike = {
id: "job-unmanaged",
name: "other",
description: "not managed",
enabled: true,
schedule: { kind: "cron", expr: "0 8 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
createdAtMs: 3,
};
const harness = createCronHarness([stalePrimary, duplicate, unmanaged]);
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: desiredConfig,
logger,
});
expect(result.status).toBe("updated");
expect(result.removed).toBe(1);
expect(harness.removeCalls).toEqual(["job-duplicate"]);
expect(harness.updateCalls).toHaveLength(1);
expect(harness.updateCalls[0]).toMatchObject({
id: "job-primary",
patch: {
enabled: true,
schedule: desired.schedule,
payload: desired.payload,
},
});
});
it("removes managed dreaming jobs when disabled", async () => {
const managedJob: CronJobLike = {
id: "job-managed",
name: constants.MANAGED_DREAMING_CRON_NAME,
description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
enabled: true,
schedule: { kind: "cron", expr: "0 3 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
createdAtMs: 10,
};
const unmanagedJob: CronJobLike = {
id: "job-other",
name: "Daily report",
description: "other",
enabled: true,
schedule: { kind: "cron", expr: "0 7 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "report" },
createdAtMs: 11,
};
const harness = createCronHarness([managedJob, unmanagedJob]);
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result).toEqual({ status: "disabled", removed: 1 });
expect(harness.removeCalls).toEqual(["job-managed"]);
expect(harness.jobs.map((entry) => entry.id)).toEqual(["job-other"]);
});
it("does not overcount removed jobs when cron remove result is unknown", async () => {
const managedJob: CronJobLike = {
id: "job-managed",
name: constants.MANAGED_DREAMING_CRON_NAME,
description: `${constants.MANAGED_DREAMING_CRON_TAG} test`,
enabled: true,
schedule: { kind: "cron", expr: "0 3 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: constants.DREAMING_SYSTEM_EVENT_TEXT },
createdAtMs: 10,
};
const harness = createCronHarness([managedJob], { removeResult: "unknown" });
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
config: {
enabled: false,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result.removed).toBe(0);
expect(harness.removeCalls).toEqual(["job-managed"]);
});
});
describe("short-term dreaming trigger", () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
tempDirs.length = 0;
});
it("applies promotions when the managed dreaming heartbeat event fires", async () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-"));
tempDirs.push(workspaceDir);
await recordShortTermRecalls({
workspaceDir,
query: "backup policy",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
});
it("keeps one-off recalls out of long-term memory under default thresholds", async () => {
const logger = createLogger();
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-dreaming-strict-"));
tempDirs.push(workspaceDir);
await recordShortTermRecalls({
workspaceDir,
query: "glacier",
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.95,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "heartbeat",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: constants.DEFAULT_DREAMING_LIMIT,
minScore: constants.DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: constants.DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: constants.DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs
.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8")
.catch((err: unknown) => {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return "";
}
throw err;
});
expect(memoryText).toBe("");
});
it("ignores non-heartbeat triggers", async () => {
const logger = createLogger();
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "user",
workspaceDir: "/tmp/workspace",
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
},
logger,
});
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,510 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
applyShortTermPromotions,
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
DEFAULT_PROMOTION_MIN_SCORE,
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
rankShortTermPromotionCandidates,
} from "./short-term-promotion.js";
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__";
const DEFAULT_DREAMING_CRON_EXPR = "0 3 * * *";
const DEFAULT_DREAMING_LIMIT = 10;
const DEFAULT_DREAMING_MIN_SCORE = DEFAULT_PROMOTION_MIN_SCORE;
const DEFAULT_DREAMING_MIN_RECALL_COUNT = DEFAULT_PROMOTION_MIN_RECALL_COUNT;
const DEFAULT_DREAMING_MIN_UNIQUE_QUERIES = DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES;
const DEFAULT_DREAMING_MODE = "off";
const DEFAULT_DREAMING_PRESET = "core";
type DreamingPreset = "core" | "deep" | "rem";
type DreamingMode = DreamingPreset | "off";
const DREAMING_PRESET_DEFAULTS: Record<
DreamingPreset,
{
cron: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
}
> = {
core: {
cron: DEFAULT_DREAMING_CRON_EXPR,
limit: DEFAULT_DREAMING_LIMIT,
minScore: DEFAULT_DREAMING_MIN_SCORE,
minRecallCount: DEFAULT_DREAMING_MIN_RECALL_COUNT,
minUniqueQueries: DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
},
deep: {
cron: "0 */12 * * *",
limit: DEFAULT_DREAMING_LIMIT,
minScore: 0.8,
minRecallCount: 3,
minUniqueQueries: 3,
},
rem: {
cron: "0 */6 * * *",
limit: DEFAULT_DREAMING_LIMIT,
minScore: 0.85,
minRecallCount: 4,
minUniqueQueries: 3,
},
};
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
type CronSchedule = { kind: "cron"; expr: string; tz?: string };
type CronPayload = { kind: "systemEvent"; text: string };
type ManagedCronJobCreate = {
name: string;
description: string;
enabled: boolean;
schedule: CronSchedule;
sessionTarget: "main";
wakeMode: "next-heartbeat";
payload: CronPayload;
};
type ManagedCronJobPatch = {
name?: string;
description?: string;
enabled?: boolean;
schedule?: CronSchedule;
sessionTarget?: "main";
wakeMode?: "next-heartbeat";
payload?: CronPayload;
};
type ManagedCronJobLike = {
id: string;
name?: string;
description?: string;
enabled?: boolean;
schedule?: {
kind?: string;
expr?: string;
tz?: string;
};
sessionTarget?: string;
wakeMode?: string;
payload?: {
kind?: string;
text?: string;
};
createdAtMs?: number;
};
type CronServiceLike = {
list: (opts?: { includeDisabled?: boolean }) => Promise<ManagedCronJobLike[]>;
add: (input: ManagedCronJobCreate) => Promise<unknown>;
update: (id: string, patch: ManagedCronJobPatch) => Promise<unknown>;
remove: (id: string) => Promise<{ removed?: boolean }>;
};
export type ShortTermPromotionDreamingConfig = {
enabled: boolean;
cron: string;
timezone?: string;
limit: number;
minScore: number;
minRecallCount: number;
minUniqueQueries: number;
};
type ReconcileResult =
| { status: "unavailable"; removed: number }
| { status: "disabled"; removed: number }
| { status: "added"; removed: number }
| { status: "updated"; removed: number }
| { status: "noop"; removed: 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>;
}
function normalizeTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function normalizeDreamingMode(value: unknown): DreamingMode {
const normalized = normalizeTrimmedString(value)?.toLowerCase();
if (
normalized === "off" ||
normalized === "core" ||
normalized === "deep" ||
normalized === "rem"
) {
return normalized;
}
return DEFAULT_DREAMING_MODE;
}
function normalizeNonNegativeInt(value: unknown, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const floored = Math.floor(value);
if (floored < 0) {
return fallback;
}
return 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 formatErrorMessage(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return String(err);
}
function resolveTimezoneFallback(cfg: OpenClawConfig | undefined): string | undefined {
const agents = asRecord(cfg?.agents);
const defaults = asRecord(agents?.defaults);
return normalizeTrimmedString(defaults?.userTimezone);
}
function resolveManagedCronDescription(config: ShortTermPromotionDreamingConfig): string {
return `${MANAGED_DREAMING_CRON_TAG} Promote weighted short-term recalls into MEMORY.md (limit=${config.limit}, minScore=${config.minScore.toFixed(3)}, minRecallCount=${config.minRecallCount}, minUniqueQueries=${config.minUniqueQueries}).`;
}
function buildManagedDreamingCronJob(
config: ShortTermPromotionDreamingConfig,
): ManagedCronJobCreate {
return {
name: MANAGED_DREAMING_CRON_NAME,
description: resolveManagedCronDescription(config),
enabled: true,
schedule: {
kind: "cron",
expr: config.cron,
...(config.timezone ? { tz: config.timezone } : {}),
},
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: {
kind: "systemEvent",
text: DREAMING_SYSTEM_EVENT_TEXT,
},
};
}
function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
const description = normalizeTrimmedString(job.description);
if (description?.includes(MANAGED_DREAMING_CRON_TAG)) {
return true;
}
const name = normalizeTrimmedString(job.name);
const payloadText = normalizeTrimmedString(job.payload?.text);
return name === MANAGED_DREAMING_CRON_NAME && payloadText === DREAMING_SYSTEM_EVENT_TEXT;
}
function compareOptionalStrings(a: string | undefined, b: string | undefined): boolean {
return a === b;
}
function buildManagedDreamingPatch(
job: ManagedCronJobLike,
desired: ManagedCronJobCreate,
): ManagedCronJobPatch | null {
const patch: ManagedCronJobPatch = {};
if (!compareOptionalStrings(normalizeTrimmedString(job.name), desired.name)) {
patch.name = desired.name;
}
if (!compareOptionalStrings(normalizeTrimmedString(job.description), desired.description)) {
patch.description = desired.description;
}
if (job.enabled !== true) {
patch.enabled = true;
}
const scheduleKind = normalizeTrimmedString(job.schedule?.kind)?.toLowerCase();
const scheduleExpr = normalizeTrimmedString(job.schedule?.expr);
const scheduleTz = normalizeTrimmedString(job.schedule?.tz);
if (
scheduleKind !== "cron" ||
!compareOptionalStrings(scheduleExpr, desired.schedule.expr) ||
!compareOptionalStrings(scheduleTz, desired.schedule.tz)
) {
patch.schedule = desired.schedule;
}
const sessionTarget = normalizeTrimmedString(job.sessionTarget)?.toLowerCase();
if (sessionTarget !== "main") {
patch.sessionTarget = "main";
}
const wakeMode = normalizeTrimmedString(job.wakeMode)?.toLowerCase();
if (wakeMode !== "next-heartbeat") {
patch.wakeMode = "next-heartbeat";
}
const payloadKind = normalizeTrimmedString(job.payload?.kind)?.toLowerCase();
const payloadText = normalizeTrimmedString(job.payload?.text);
if (payloadKind !== "systemevent" || !compareOptionalStrings(payloadText, desired.payload.text)) {
patch.payload = desired.payload;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function sortManagedJobs(managed: ManagedCronJobLike[]): ManagedCronJobLike[] {
return managed.toSorted((a, b) => {
const aCreated =
typeof a.createdAtMs === "number" && Number.isFinite(a.createdAtMs)
? a.createdAtMs
: Number.MAX_SAFE_INTEGER;
const bCreated =
typeof b.createdAtMs === "number" && Number.isFinite(b.createdAtMs)
? b.createdAtMs
: Number.MAX_SAFE_INTEGER;
if (aCreated !== bCreated) {
return aCreated - bCreated;
}
return a.id.localeCompare(b.id);
});
}
function resolveCronServiceFromStartupEvent(event: unknown): CronServiceLike | null {
const payload = asRecord(event);
if (!payload) {
return null;
}
if (payload.type !== "gateway" || payload.action !== "startup") {
return null;
}
const context = asRecord(payload.context);
const deps = asRecord(context?.deps);
const cronCandidate = context?.cron ?? deps?.cron;
if (!cronCandidate || typeof cronCandidate !== "object") {
return null;
}
const cron = cronCandidate as Partial<CronServiceLike>;
if (
typeof cron.list !== "function" ||
typeof cron.add !== "function" ||
typeof cron.update !== "function" ||
typeof cron.remove !== "function"
) {
return null;
}
return cron as CronServiceLike;
}
export function resolveShortTermPromotionDreamingConfig(params: {
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
}): ShortTermPromotionDreamingConfig {
const dreaming = asRecord(params.pluginConfig?.dreaming);
const mode = normalizeDreamingMode(dreaming?.mode);
const enabled = mode !== "off";
const thresholdPreset: DreamingPreset = mode === "off" ? DEFAULT_DREAMING_PRESET : mode;
const thresholdDefaults = DREAMING_PRESET_DEFAULTS[thresholdPreset];
const cron = normalizeTrimmedString(dreaming?.frequency) ?? thresholdDefaults.cron;
const timezone =
normalizeTrimmedString(dreaming?.timezone) ?? resolveTimezoneFallback(params.cfg);
const limit = normalizeNonNegativeInt(dreaming?.limit, thresholdDefaults.limit);
const minScore = normalizeScore(dreaming?.minScore, thresholdDefaults.minScore);
const minRecallCount = normalizeNonNegativeInt(
dreaming?.minRecallCount,
thresholdDefaults.minRecallCount,
);
const minUniqueQueries = normalizeNonNegativeInt(
dreaming?.minUniqueQueries,
thresholdDefaults.minUniqueQueries,
);
return {
enabled,
cron,
...(timezone ? { timezone } : {}),
limit,
minScore,
minRecallCount,
minUniqueQueries,
};
}
export async function reconcileShortTermDreamingCronJob(params: {
cron: CronServiceLike | null;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
}): Promise<ReconcileResult> {
const cron = params.cron;
if (!cron) {
return { status: "unavailable", removed: 0 };
}
const allJobs = await cron.list({ includeDisabled: true });
const managed = allJobs.filter(isManagedDreamingJob);
if (!params.config.enabled) {
let removed = 0;
for (const job of managed) {
const result = await cron.remove(job.id);
if (result.removed === true) {
removed += 1;
}
}
if (removed > 0) {
params.logger.info(`memory-core: removed ${removed} managed dreaming cron job(s).`);
}
return { status: "disabled", removed };
}
const desired = buildManagedDreamingCronJob(params.config);
if (managed.length === 0) {
await cron.add(desired);
params.logger.info("memory-core: created managed dreaming cron job.");
return { status: "added", removed: 0 };
}
const [primary, ...duplicates] = sortManagedJobs(managed);
let removed = 0;
for (const duplicate of duplicates) {
const result = await cron.remove(duplicate.id);
if (result.removed === true) {
removed += 1;
}
}
const patch = buildManagedDreamingPatch(primary, desired);
if (!patch) {
if (removed > 0) {
params.logger.info("memory-core: pruned duplicate managed dreaming cron jobs.");
}
return { status: "noop", removed };
}
await cron.update(primary.id, patch);
params.logger.info("memory-core: updated managed dreaming cron job.");
return { status: "updated", removed };
}
export async function runShortTermDreamingPromotionIfTriggered(params: {
cleanedBody: string;
trigger?: string;
workspaceDir?: string;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
}): Promise<{ handled: true; reason: string } | undefined> {
if (params.trigger !== "heartbeat") {
return undefined;
}
if (params.cleanedBody.trim() !== DREAMING_SYSTEM_EVENT_TEXT) {
return undefined;
}
if (!params.config.enabled) {
return { handled: true, reason: "memory-core: short-term dreaming disabled" };
}
const workspaceDir = normalizeTrimmedString(params.workspaceDir);
if (!workspaceDir) {
params.logger.warn(
"memory-core: dreaming promotion skipped because workspaceDir is unavailable.",
);
return { handled: true, reason: "memory-core: short-term dreaming missing workspace" };
}
try {
const candidates = await rankShortTermPromotionCandidates({
workspaceDir,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
minUniqueQueries: params.config.minUniqueQueries,
});
const applied = await applyShortTermPromotions({
workspaceDir,
candidates,
limit: params.config.limit,
minScore: params.config.minScore,
minRecallCount: params.config.minRecallCount,
minUniqueQueries: params.config.minUniqueQueries,
});
params.logger.info(
`memory-core: dreaming promotion complete (candidates=${candidates.length}, applied=${applied.applied}).`,
);
} catch (err) {
params.logger.error(`memory-core: dreaming promotion failed: ${formatErrorMessage(err)}`);
}
return { handled: true, reason: "memory-core: short-term dreaming processed" };
}
export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void {
api.registerHook(
"gateway:startup",
async (event: unknown) => {
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig: api.pluginConfig,
cfg: api.config,
});
const cron = resolveCronServiceFromStartupEvent(event);
if (!cron && config.enabled) {
api.logger.warn(
"memory-core: managed dreaming cron could not be reconciled (cron service unavailable).",
);
}
await reconcileShortTermDreamingCronJob({
cron,
config,
logger: api.logger,
});
},
{ name: "memory-core-short-term-dreaming-cron" },
);
api.on("before_agent_reply", async (event, ctx) => {
const config = resolveShortTermPromotionDreamingConfig({
pluginConfig: api.pluginConfig,
cfg: api.config,
});
return await runShortTermDreamingPromotionIfTriggered({
cleanedBody: event.cleanedBody,
trigger: ctx.trigger,
workspaceDir: ctx.workspaceDir,
config,
logger: api.logger,
});
});
}
export const __testing = {
buildManagedDreamingCronJob,
buildManagedDreamingPatch,
isManagedDreamingJob,
resolveCronServiceFromStartupEvent,
constants: {
MANAGED_DREAMING_CRON_NAME,
MANAGED_DREAMING_CRON_TAG,
DREAMING_SYSTEM_EVENT_TEXT,
DEFAULT_DREAMING_MODE,
DEFAULT_DREAMING_PRESET,
DEFAULT_DREAMING_CRON_EXPR,
DEFAULT_DREAMING_LIMIT,
DEFAULT_DREAMING_MIN_SCORE,
DEFAULT_DREAMING_MIN_RECALL_COUNT,
DEFAULT_DREAMING_MIN_UNIQUE_QUERIES,
DREAMING_PRESET_DEFAULTS,
},
};

View File

@@ -0,0 +1,308 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
applyShortTermPromotions,
isShortTermMemoryPath,
rankShortTermPromotionCandidates,
recordShortTermRecalls,
resolveShortTermRecallStorePath,
} from "./short-term-promotion.js";
describe("short-term promotion", () => {
async function withTempWorkspace(run: (workspaceDir: string) => Promise<void>) {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-promote-"));
try {
await run(workspaceDir);
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
}
it("detects short-term daily memory paths", () => {
expect(isShortTermMemoryPath("memory/2026-04-03.md")).toBe(true);
expect(isShortTermMemoryPath("2026-04-03.md")).toBe(true);
expect(isShortTermMemoryPath("notes/2026-04-03.md")).toBe(false);
expect(isShortTermMemoryPath("MEMORY.md")).toBe(false);
expect(isShortTermMemoryPath("memory/network.md")).toBe(false);
});
it("records recalls and ranks candidates with weighted scores", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "router",
results: [
{
path: "memory/2026-04-02.md",
startLine: 3,
endLine: 5,
score: 0.9,
snippet: "Configured VLAN 10 on Omada router",
source: "memory",
},
{
path: "MEMORY.md",
startLine: 1,
endLine: 1,
score: 0.99,
snippet: "Long-term note",
source: "memory",
},
],
});
await recordShortTermRecalls({
workspaceDir,
query: "iot vlan",
results: [
{
path: "memory/2026-04-02.md",
startLine: 3,
endLine: 5,
score: 0.8,
snippet: "Configured VLAN 10 on Omada router",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(ranked).toHaveLength(1);
expect(ranked[0]?.path).toBe("memory/2026-04-02.md");
expect(ranked[0]?.recallCount).toBe(2);
expect(ranked[0]?.uniqueQueries).toBe(2);
expect(ranked[0]?.score).toBeGreaterThan(0);
const storePath = resolveShortTermRecallStorePath(workspaceDir);
const raw = await fs.readFile(storePath, "utf-8");
expect(raw).toContain("memory/2026-04-02.md");
expect(raw).not.toContain("Long-term note");
});
});
it("serializes concurrent recall writes so counts are not lost", async () => {
await withTempWorkspace(async (workspaceDir) => {
await Promise.all(
Array.from({ length: 20 }, (_, index) =>
recordShortTermRecalls({
workspaceDir,
query: `backup-${index % 4}`,
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
}),
),
);
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(ranked).toHaveLength(1);
expect(ranked[0]?.recallCount).toBe(20);
expect(ranked[0]?.uniqueQueries).toBe(4);
});
});
it("uses default thresholds for promotion", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "glacier",
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.96,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({ workspaceDir });
expect(ranked).toHaveLength(0);
});
});
it("treats negative threshold overrides as invalid and keeps defaults", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "glacier",
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.96,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: -1,
minRecallCount: -1,
minUniqueQueries: -1,
});
expect(ranked).toHaveLength(0);
});
});
it("enforces default thresholds during apply even when candidates are passed directly", async () => {
await withTempWorkspace(async (workspaceDir) => {
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: [
{
key: "memory:memory/2026-04-03.md:1:2",
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
source: "memory",
snippet: "Move backups to S3 Glacier.",
recallCount: 1,
avgScore: 0.95,
maxScore: 0.95,
uniqueQueries: 1,
firstRecalledAt: new Date().toISOString(),
lastRecalledAt: new Date().toISOString(),
ageDays: 0,
score: 0.95,
components: {
frequency: 0.2,
relevance: 0.95,
diversity: 0.2,
recency: 1,
},
},
],
});
expect(applied.applied).toBe(0);
});
});
it("applies promotion candidates to MEMORY.md and marks them promoted", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "gateway host",
results: [
{
path: "memory/2026-04-01.md",
startLine: 10,
endLine: 12,
score: 0.92,
snippet: "Gateway binds loopback and port 18789",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
const applied = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(applied.applied).toBe(1);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Promoted From Short-Term Memory");
expect(memoryText).toContain("memory/2026-04-01.md:10-12");
const rankedAfter = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(rankedAfter).toHaveLength(0);
const rankedIncludingPromoted = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
includePromoted: true,
});
expect(rankedIncludingPromoted).toHaveLength(1);
expect(rankedIncludingPromoted[0]?.promotedAt).toBeTruthy();
});
});
it("does not re-append candidates that were promoted in a prior run", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "gateway host",
results: [
{
path: "memory/2026-04-01.md",
startLine: 10,
endLine: 12,
score: 0.92,
snippet: "Gateway binds loopback and port 18789",
source: "memory",
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
const first = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(first.applied).toBe(1);
const second = await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
expect(second.applied).toBe(0);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
const sectionCount = memoryText.match(/Promoted From Short-Term Memory/g)?.length ?? 0;
expect(sectionCount).toBe(1);
});
});
});

View File

@@ -0,0 +1,695 @@
import { createHash, randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
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 DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_RECENCY_HALF_LIFE_DAYS = 14;
export const DEFAULT_PROMOTION_MIN_SCORE = 0.75;
export const DEFAULT_PROMOTION_MIN_RECALL_COUNT = 3;
export const DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES = 2;
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
const SHORT_TERM_LOCK_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-promotion.lock");
const SHORT_TERM_LOCK_WAIT_TIMEOUT_MS = 10_000;
const SHORT_TERM_LOCK_STALE_MS = 60_000;
const SHORT_TERM_LOCK_RETRY_DELAY_MS = 40;
export type PromotionWeights = {
frequency: number;
relevance: number;
diversity: number;
recency: number;
};
export const DEFAULT_PROMOTION_WEIGHTS: PromotionWeights = {
frequency: 0.35,
relevance: 0.35,
diversity: 0.15,
recency: 0.15,
};
export type ShortTermRecallEntry = {
key: string;
path: string;
startLine: number;
endLine: number;
source: "memory";
snippet: string;
recallCount: number;
totalScore: number;
maxScore: number;
firstRecalledAt: string;
lastRecalledAt: string;
queryHashes: string[];
promotedAt?: string;
};
type ShortTermRecallStore = {
version: 1;
updatedAt: string;
entries: Record<string, ShortTermRecallEntry>;
};
export type PromotionComponents = {
frequency: number;
relevance: number;
diversity: number;
recency: number;
};
export type PromotionCandidate = {
key: string;
path: string;
startLine: number;
endLine: number;
source: "memory";
snippet: string;
recallCount: number;
avgScore: number;
maxScore: number;
uniqueQueries: number;
promotedAt?: string;
firstRecalledAt: string;
lastRecalledAt: string;
ageDays: number;
score: number;
components: PromotionComponents;
};
export type RankShortTermPromotionOptions = {
workspaceDir: string;
limit?: number;
minScore?: number;
minRecallCount?: number;
minUniqueQueries?: number;
includePromoted?: boolean;
recencyHalfLifeDays?: number;
weights?: Partial<PromotionWeights>;
nowMs?: number;
};
export type ApplyShortTermPromotionsOptions = {
workspaceDir: string;
candidates: PromotionCandidate[];
limit?: number;
minScore?: number;
minRecallCount?: number;
minUniqueQueries?: number;
nowMs?: number;
};
export type ApplyShortTermPromotionsResult = {
memoryPath: string;
applied: number;
appliedCandidates: PromotionCandidate[];
};
function clampScore(value: number): number {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
}
function toFiniteScore(value: unknown, fallback: number): number {
const num = Number(value);
if (!Number.isFinite(num)) {
return fallback;
}
if (num < 0 || num > 1) {
return fallback;
}
return num;
}
function normalizeSnippet(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return "";
}
return trimmed.replace(/\s+/g, " ");
}
function normalizeMemoryPath(rawPath: string): string {
return rawPath.replaceAll("\\", "/").replace(/^\.\//, "");
}
function buildEntryKey(result: {
path: string;
startLine: number;
endLine: number;
source: string;
}): string {
return `${result.source}:${normalizeMemoryPath(result.path)}:${result.startLine}:${result.endLine}`;
}
function hashQuery(query: string): string {
return createHash("sha1").update(query.trim().toLowerCase()).digest("hex").slice(0, 12);
}
function mergeQueryHashes(existing: string[], queryHash: string): string[] {
if (!queryHash) {
return existing;
}
const next = existing.filter(Boolean);
if (!next.includes(queryHash)) {
next.push(queryHash);
}
const maxHashes = 32;
if (next.length <= maxHashes) {
return next;
}
return next.slice(next.length - maxHashes);
}
function emptyStore(nowIso: string): ShortTermRecallStore {
return {
version: 1,
updatedAt: nowIso,
entries: {},
};
}
function normalizeStore(raw: unknown, nowIso: string): ShortTermRecallStore {
if (!raw || typeof raw !== "object") {
return emptyStore(nowIso);
}
const record = raw as Record<string, unknown>;
const entriesRaw = record.entries;
const entries: Record<string, ShortTermRecallEntry> = {};
if (entriesRaw && typeof entriesRaw === "object") {
for (const [key, value] of Object.entries(entriesRaw as Record<string, unknown>)) {
if (!value || typeof value !== "object") {
continue;
}
const entry = value as Record<string, unknown>;
const entryPath = typeof entry.path === "string" ? normalizeMemoryPath(entry.path) : "";
const startLine = Number(entry.startLine);
const endLine = Number(entry.endLine);
const source = entry.source === "memory" ? "memory" : null;
if (!entryPath || !Number.isInteger(startLine) || !Number.isInteger(endLine) || !source) {
continue;
}
const recallCount = Math.max(0, Math.floor(Number(entry.recallCount) || 0));
const totalScore = Math.max(0, Number(entry.totalScore) || 0);
const maxScore = clampScore(Number(entry.maxScore) || 0);
const firstRecalledAt =
typeof entry.firstRecalledAt === "string" ? entry.firstRecalledAt : nowIso;
const lastRecalledAt =
typeof entry.lastRecalledAt === "string" ? entry.lastRecalledAt : nowIso;
const promotedAt = typeof entry.promotedAt === "string" ? entry.promotedAt : undefined;
const snippet = typeof entry.snippet === "string" ? normalizeSnippet(entry.snippet) : "";
const queryHashes = Array.isArray(entry.queryHashes)
? entry.queryHashes.filter(
(hash): hash is string => typeof hash === "string" && hash.length > 0,
)
: [];
const normalizedKey = key || buildEntryKey({ path: entryPath, startLine, endLine, source });
entries[normalizedKey] = {
key: normalizedKey,
path: entryPath,
startLine,
endLine,
source,
snippet,
recallCount,
totalScore,
maxScore,
firstRecalledAt,
lastRecalledAt,
queryHashes,
...(promotedAt ? { promotedAt } : {}),
};
}
}
return {
version: 1,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : nowIso,
entries,
};
}
function toFinitePositive(value: unknown, fallback: number): number {
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) {
return fallback;
}
return num;
}
function toFiniteNonNegativeInt(value: unknown, fallback: number): number {
const num = Number(value);
if (!Number.isFinite(num)) {
return fallback;
}
const floored = Math.floor(num);
if (floored < 0) {
return fallback;
}
return floored;
}
function normalizeWeights(weights?: Partial<PromotionWeights>): PromotionWeights {
const merged = {
...DEFAULT_PROMOTION_WEIGHTS,
...(weights ?? {}),
};
const frequency = Math.max(0, merged.frequency);
const relevance = Math.max(0, merged.relevance);
const diversity = Math.max(0, merged.diversity);
const recency = Math.max(0, merged.recency);
const sum = frequency + relevance + diversity + recency;
if (sum <= 0) {
return { ...DEFAULT_PROMOTION_WEIGHTS };
}
return {
frequency: frequency / sum,
relevance: relevance / sum,
diversity: diversity / sum,
recency: recency / sum,
};
}
function calculateRecencyComponent(ageDays: number, halfLifeDays: number): number {
if (!Number.isFinite(ageDays) || ageDays < 0) {
return 1;
}
if (!Number.isFinite(halfLifeDays) || halfLifeDays <= 0) {
return 1;
}
const lambda = Math.LN2 / halfLifeDays;
return Math.exp(-lambda * ageDays);
}
function resolveStorePath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
}
function resolveLockPath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_LOCK_RELATIVE_PATH);
}
function parseLockOwnerPid(raw: string): number | null {
const match = raw.trim().match(/^(\d+):/);
if (!match) {
return null;
}
const pid = Number.parseInt(match[1] ?? "", 10);
if (!Number.isInteger(pid) || pid <= 0) {
return null;
}
return pid;
}
function isProcessLikelyAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
if (code === "ESRCH") {
return false;
}
// EPERM and unknown errors are treated as alive to avoid stealing active locks.
return true;
}
}
async function canStealStaleLock(lockPath: string): Promise<boolean> {
const ownerPid = await fs
.readFile(lockPath, "utf-8")
.then((raw) => parseLockOwnerPid(raw))
.catch(() => null);
if (ownerPid === null) {
return true;
}
return !isProcessLikelyAlive(ownerPid);
}
async function sleep(ms: number): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
async function withShortTermLock<T>(workspaceDir: string, task: () => Promise<T>): Promise<T> {
const lockPath = resolveLockPath(workspaceDir);
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const startedAt = Date.now();
while (true) {
let lockHandle: Awaited<ReturnType<typeof fs.open>> | undefined;
try {
lockHandle = await fs.open(lockPath, "wx");
await lockHandle.writeFile(`${process.pid}:${Date.now()}\n`, "utf-8").catch(() => undefined);
try {
return await task();
} finally {
await lockHandle.close().catch(() => undefined);
await fs.unlink(lockPath).catch(() => undefined);
}
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") {
throw err;
}
const ageMs = await fs
.stat(lockPath)
.then((stats) => Date.now() - stats.mtimeMs)
.catch(() => 0);
if (ageMs > SHORT_TERM_LOCK_STALE_MS) {
if (await canStealStaleLock(lockPath)) {
await fs.unlink(lockPath).catch(() => undefined);
continue;
}
}
if (Date.now() - startedAt >= SHORT_TERM_LOCK_WAIT_TIMEOUT_MS) {
throw new Error(`Timed out waiting for short-term promotion lock at ${lockPath}`);
}
await sleep(SHORT_TERM_LOCK_RETRY_DELAY_MS);
}
}
}
async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTermRecallStore> {
const storePath = resolveStorePath(workspaceDir);
try {
const raw = await fs.readFile(storePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
return normalizeStore(parsed, nowIso);
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return emptyStore(nowIso);
}
throw err;
}
}
async function writeStore(workspaceDir: string, store: ShortTermRecallStore): Promise<void> {
const storePath = resolveStorePath(workspaceDir);
await fs.mkdir(path.dirname(storePath), { recursive: true });
const tmpPath = `${storePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await fs.writeFile(tmpPath, `${JSON.stringify(store, null, 2)}\n`, "utf-8");
await fs.rename(tmpPath, storePath);
}
export function isShortTermMemoryPath(filePath: string): boolean {
const normalized = normalizeMemoryPath(filePath);
if (SHORT_TERM_PATH_RE.test(normalized)) {
return true;
}
return SHORT_TERM_BASENAME_RE.test(normalized);
}
export async function recordShortTermRecalls(params: {
workspaceDir?: string;
query: string;
results: MemorySearchResult[];
nowMs?: number;
}): Promise<void> {
const workspaceDir = params.workspaceDir?.trim();
if (!workspaceDir) {
return;
}
const query = params.query.trim();
if (!query) {
return;
}
const relevant = params.results.filter(
(result) => result.source === "memory" && isShortTermMemoryPath(result.path),
);
if (relevant.length === 0) {
return;
}
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const queryHash = hashQuery(query);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
for (const result of relevant) {
const key = buildEntryKey(result);
const normalizedPath = normalizeMemoryPath(result.path);
const existing = store.entries[key];
const snippet = normalizeSnippet(result.snippet);
const score = clampScore(result.score);
const recallCount = Math.max(1, Math.floor(existing?.recallCount ?? 0) + 1);
const totalScore = Math.max(0, (existing?.totalScore ?? 0) + score);
const maxScore = Math.max(existing?.maxScore ?? 0, score);
const queryHashes = mergeQueryHashes(existing?.queryHashes ?? [], queryHash);
store.entries[key] = {
key,
path: normalizedPath,
startLine: Math.max(1, Math.floor(result.startLine)),
endLine: Math.max(1, Math.floor(result.endLine)),
source: "memory",
snippet: snippet || existing?.snippet || "",
recallCount,
totalScore,
maxScore,
firstRecalledAt: existing?.firstRecalledAt ?? nowIso,
lastRecalledAt: nowIso,
queryHashes,
...(existing?.promotedAt ? { promotedAt: existing.promotedAt } : {}),
};
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store);
});
}
export async function rankShortTermPromotionCandidates(
options: RankShortTermPromotionOptions,
): Promise<PromotionCandidate[]> {
const workspaceDir = options.workspaceDir.trim();
if (!workspaceDir) {
return [];
}
const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const minScore = toFiniteScore(options.minScore, DEFAULT_PROMOTION_MIN_SCORE);
const minRecallCount = toFiniteNonNegativeInt(
options.minRecallCount,
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
);
const minUniqueQueries = toFiniteNonNegativeInt(
options.minUniqueQueries,
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
);
const includePromoted = Boolean(options.includePromoted);
const halfLifeDays = toFinitePositive(
options.recencyHalfLifeDays,
DEFAULT_RECENCY_HALF_LIFE_DAYS,
);
const weights = normalizeWeights(options.weights);
const store = await readStore(workspaceDir, nowIso);
const candidates: PromotionCandidate[] = [];
for (const entry of Object.values(store.entries)) {
if (!entry || entry.source !== "memory" || !isShortTermMemoryPath(entry.path)) {
continue;
}
if (!includePromoted && entry.promotedAt) {
continue;
}
if (!Number.isFinite(entry.recallCount) || entry.recallCount <= 0) {
continue;
}
if (entry.recallCount < minRecallCount) {
continue;
}
const avgScore = clampScore(entry.totalScore / Math.max(1, entry.recallCount));
const frequency = clampScore(Math.log1p(entry.recallCount) / Math.log1p(10));
const uniqueQueries = entry.queryHashes?.length ?? 0;
if (uniqueQueries < minUniqueQueries) {
continue;
}
const diversity = clampScore(uniqueQueries / 5);
const lastRecalledAtMs = Date.parse(entry.lastRecalledAt);
const ageDays = Number.isFinite(lastRecalledAtMs)
? Math.max(0, (nowMs - lastRecalledAtMs) / DAY_MS)
: 0;
const recency = clampScore(calculateRecencyComponent(ageDays, halfLifeDays));
const score =
weights.frequency * frequency +
weights.relevance * avgScore +
weights.diversity * diversity +
weights.recency * recency;
if (score < minScore) {
continue;
}
candidates.push({
key: entry.key,
path: entry.path,
startLine: entry.startLine,
endLine: entry.endLine,
source: entry.source,
snippet: entry.snippet,
recallCount: entry.recallCount,
avgScore,
maxScore: clampScore(entry.maxScore),
uniqueQueries,
promotedAt: entry.promotedAt,
firstRecalledAt: entry.firstRecalledAt,
lastRecalledAt: entry.lastRecalledAt,
ageDays,
score: clampScore(score),
components: {
frequency,
relevance: avgScore,
diversity,
recency,
},
});
}
const sorted = candidates.toSorted((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
if (b.recallCount !== a.recallCount) {
return b.recallCount - a.recallCount;
}
return a.path.localeCompare(b.path);
});
const limit = Number.isFinite(options.limit)
? Math.max(0, Math.floor(options.limit as number))
: sorted.length;
return sorted.slice(0, limit);
}
function buildPromotionSection(candidates: PromotionCandidate[], nowMs: number): string {
const sectionDate = new Date(nowMs).toISOString().slice(0, 10);
const lines = ["", `## Promoted From Short-Term Memory (${sectionDate})`, ""];
for (const candidate of candidates) {
const source = `${candidate.path}:${candidate.startLine}-${candidate.endLine}`;
const snippet = candidate.snippet || "(no snippet captured)";
lines.push(
`- ${snippet} [score=${candidate.score.toFixed(3)} recalls=${candidate.recallCount} avg=${candidate.avgScore.toFixed(3)} source=${source}]`,
);
}
lines.push("");
return lines.join("\n");
}
function withTrailingNewline(content: string): string {
if (!content) {
return "";
}
return content.endsWith("\n") ? content : `${content}\n`;
}
export async function applyShortTermPromotions(
options: ApplyShortTermPromotionsOptions,
): Promise<ApplyShortTermPromotionsResult> {
const workspaceDir = options.workspaceDir.trim();
const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const limit = Number.isFinite(options.limit)
? Math.max(0, Math.floor(options.limit as number))
: options.candidates.length;
const minScore = toFiniteScore(options.minScore, DEFAULT_PROMOTION_MIN_SCORE);
const minRecallCount = toFiniteNonNegativeInt(
options.minRecallCount,
DEFAULT_PROMOTION_MIN_RECALL_COUNT,
);
const minUniqueQueries = toFiniteNonNegativeInt(
options.minUniqueQueries,
DEFAULT_PROMOTION_MIN_UNIQUE_QUERIES,
);
const memoryPath = path.join(workspaceDir, "MEMORY.md");
return await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
const selected = options.candidates
.filter((candidate) => {
if (candidate.promotedAt) {
return false;
}
if (candidate.score < minScore) {
return false;
}
if (candidate.recallCount < minRecallCount) {
return false;
}
if (candidate.uniqueQueries < minUniqueQueries) {
return false;
}
const latest = store.entries[candidate.key];
if (latest?.promotedAt) {
return false;
}
return true;
})
.slice(0, limit);
if (selected.length === 0) {
return {
memoryPath,
applied: 0,
appliedCandidates: [],
};
}
const existingMemory = await fs.readFile(memoryPath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
const header = existingMemory.trim().length > 0 ? "" : "# Long-Term Memory\n\n";
const section = buildPromotionSection(selected, nowMs);
await fs.writeFile(
memoryPath,
`${header}${withTrailingNewline(existingMemory)}${section}`,
"utf-8",
);
for (const candidate of selected) {
const entry = store.entries[candidate.key];
if (!entry) {
continue;
}
entry.promotedAt = nowIso;
}
store.updatedAt = nowIso;
await writeStore(workspaceDir, store);
return {
memoryPath,
applied: selected.length,
appliedCandidates: selected,
};
});
}
export function resolveShortTermRecallStorePath(workspaceDir: string): string {
return resolveStorePath(workspaceDir);
}
export const __testing = {
parseLockOwnerPid,
canStealStaleLock,
isProcessLikelyAlive,
};

View File

@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it } from "vitest";
import {
getMemorySearchManagerMockCalls,
@@ -6,6 +9,7 @@ import {
setMemoryBackend,
setMemoryReadFileImpl,
setMemorySearchImpl,
setMemoryWorkspaceDir,
type MemoryReadParams,
} from "../../../test/helpers/memory-tool-manager-mock.js";
import {
@@ -17,6 +21,25 @@ import {
expectUnavailableMemorySearchDetails,
} from "./tools.test-helpers.js";
async function waitFor<T>(task: () => Promise<T>, timeoutMs: number = 1500): Promise<T> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
return await task();
} catch (error) {
lastError = error;
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
}
if (lastError instanceof Error) {
throw lastError;
}
throw new Error("Timed out waiting for async test condition");
}
beforeEach(() => {
resetMemoryToolMockState({
backend: "builtin",
@@ -150,4 +173,39 @@ describe("memory tools", () => {
expect(getReadAgentMemoryFileMockCalls()).toBe(1);
expect(getMemorySearchManagerMockCalls()).toBe(0);
});
it("persists short-term recall events from memory_search tool hits", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "memory-tools-recall-"));
try {
setMemoryBackend("builtin");
setMemoryWorkspaceDir(workspaceDir);
setMemorySearchImpl(async () => [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.95,
snippet: "Move backups to S3 Glacier.",
source: "memory" as const,
},
]);
const tool = createMemorySearchToolOrThrow();
await tool.execute("call_recall_persist", { query: "glacier backup" });
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
const store = JSON.parse(storeRaw) as {
entries?: Record<string, { path: string; recallCount: number }>;
};
const entries = Object.values(store.entries ?? {});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
path: "memory/2026-04-03.md",
recallCount: 1,
});
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,138 @@
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
resetMemoryToolMockState,
setMemoryBackend,
setMemorySearchImpl,
} from "../../../test/helpers/memory-tool-manager-mock.js";
import type { OpenClawConfig } from "../api.js";
import { createMemorySearchTool } from "./tools.js";
type RecordShortTermRecallsFn = (params: {
workspaceDir?: string;
query: string;
results: MemorySearchResult[];
nowMs?: number;
}) => Promise<void>;
const recallTrackingMock = vi.hoisted(() => ({
recordShortTermRecalls: vi.fn<RecordShortTermRecallsFn>(async () => {}),
}));
vi.mock("./short-term-promotion.js", () => ({
recordShortTermRecalls: recallTrackingMock.recordShortTermRecalls,
}));
function asOpenClawConfig(config: Partial<OpenClawConfig>): OpenClawConfig {
return config as OpenClawConfig;
}
function createSearchTool(config: OpenClawConfig) {
const tool = createMemorySearchTool({ config });
if (!tool) {
throw new Error("memory_search tool missing");
}
return tool;
}
describe("memory_search recall tracking", () => {
beforeEach(() => {
resetMemoryToolMockState();
recallTrackingMock.recordShortTermRecalls.mockReset();
recallTrackingMock.recordShortTermRecalls.mockResolvedValue(undefined);
});
it("records only surfaced results after qmd clamp", async () => {
setMemoryBackend("qmd");
setMemorySearchImpl(async () => [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.95,
snippet: "A".repeat(80),
source: "memory" as const,
},
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 2,
score: 0.92,
snippet: "B".repeat(80),
source: "memory" as const,
},
]);
const tool = createSearchTool(
asOpenClawConfig({
agents: { list: [{ id: "main", default: true }] },
memory: {
backend: "qmd",
citations: "on",
qmd: { limits: { maxInjectedChars: 100 } },
},
}),
);
const result = await tool.execute("call_recall_clamp", { query: "backup glacier" });
const details = result.details as { results: Array<{ path: string }> };
expect(details.results).toHaveLength(1);
expect(details.results[0]?.path).toBe("memory/2026-04-03.md");
expect(recallTrackingMock.recordShortTermRecalls).toHaveBeenCalledTimes(1);
const [firstCall] = recallTrackingMock.recordShortTermRecalls.mock.calls;
expect(firstCall).toBeDefined();
const recallParams = firstCall[0];
expect(recallParams.results).toHaveLength(1);
expect(recallParams.results[0]?.path).toBe("memory/2026-04-03.md");
expect(recallParams.results[0]?.snippet).not.toContain("Source:");
});
it("does not block tool results on slow best-effort recall writes", async () => {
let resolveRecall: (() => void) | undefined;
recallTrackingMock.recordShortTermRecalls.mockImplementationOnce(
async () =>
await new Promise<void>((resolve) => {
resolveRecall = resolve;
}),
);
const tool = createSearchTool(
asOpenClawConfig({
agents: { list: [{ id: "main", default: true }] },
}),
);
setMemorySearchImpl(async () => [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.95,
snippet: "Move backups to S3 Glacier.",
source: "memory" as const,
},
]);
let timeout: NodeJS.Timeout | undefined;
try {
const result = (await Promise.race([
tool.execute("call_recall_non_blocking", { query: "glacier" }),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error("memory_search waited on recall persistence"));
}, 200);
}),
])) as Awaited<ReturnType<typeof tool.execute>>;
const details = result.details as { results: Array<{ path: string }> };
expect(details.results).toHaveLength(1);
expect(details.results[0]?.path).toBe("memory/2026-04-03.md");
expect(recallTrackingMock.recordShortTermRecalls).toHaveBeenCalledTimes(1);
} finally {
if (timeout) {
clearTimeout(timeout);
}
resolveRecall?.();
}
});
});

View File

@@ -5,6 +5,8 @@ import {
type AnyAgentTool,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import { recordShortTermRecalls } from "./short-term-promotion.js";
import {
clampResultsByInjectedChars,
decorateCitations,
@@ -21,6 +23,45 @@ import {
MemorySearchSchema,
} from "./tools.shared.js";
function buildRecallKey(
result: Pick<MemorySearchResult, "source" | "path" | "startLine" | "endLine">,
): string {
return `${result.source}:${result.path}:${result.startLine}:${result.endLine}`;
}
function resolveRecallTrackingResults(
rawResults: MemorySearchResult[],
surfacedResults: MemorySearchResult[],
): MemorySearchResult[] {
if (surfacedResults.length === 0 || rawResults.length === 0) {
return surfacedResults;
}
const rawByKey = new Map<string, MemorySearchResult>();
for (const raw of rawResults) {
const key = buildRecallKey(raw);
if (!rawByKey.has(key)) {
rawByKey.set(key, raw);
}
}
return surfacedResults.map((surfaced) => rawByKey.get(buildRecallKey(surfaced)) ?? surfaced);
}
function queueShortTermRecallTracking(params: {
workspaceDir?: string;
query: string;
rawResults: MemorySearchResult[];
surfacedResults: MemorySearchResult[];
}): void {
const trackingResults = resolveRecallTrackingResults(params.rawResults, params.surfacedResults);
void recordShortTermRecalls({
workspaceDir: params.workspaceDir,
query: params.query,
results: trackingResults,
}).catch(() => {
// Recall tracking is best-effort and must never block memory recall.
});
}
export function createMemorySearchTool(options: {
config?: OpenClawConfig;
agentSessionKey?: string;
@@ -61,6 +102,12 @@ export function createMemorySearchTool(options: {
status.backend === "qmd"
? clampResultsByInjectedChars(decorated, resolved.qmd?.limits.maxInjectedChars)
: decorated;
queueShortTermRecallTracking({
workspaceDir: status.workspaceDir,
query,
rawResults,
surfacedResults: results,
});
const searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
return jsonResult({
results,