Dreaming: simplify sweep flow and add diary surface

This commit is contained in:
Vignesh Natarajan
2026-04-05 17:16:49 -07:00
parent 02f2a66dff
commit 61e61ccc18
44 changed files with 4375 additions and 1470 deletions

View File

@@ -66,11 +66,11 @@ function createCommandContext(args?: string): PluginCommandContext {
}
describe("memory-core /dreaming command", () => {
it("registers with a phase-oriented description", () => {
it("registers with an enable/disable description", () => {
const { command } = createHarness();
expect(command.name).toBe("dreaming");
expect(command.acceptsArgs).toBe(true);
expect(command.description).toContain("dreaming phases");
expect(command.description).toContain("Enable or disable");
});
it("shows phase explanations when invoked without args", async () => {
@@ -79,11 +79,10 @@ describe("memory-core /dreaming command", () => {
expect(result.text).toContain("Usage: /dreaming status");
expect(result.text).toContain("Dreaming status:");
expect(result.text).toContain("- light: sorts recent memory traces into DREAMS.md.");
expect(result.text).toContain("- implementation detail: each sweep runs light -> REM -> deep.");
expect(result.text).toContain(
"- deep: promotes durable memories into MEMORY.md and handles recovery when memory is thin.",
"- deep is the only stage that writes durable entries to MEMORY.md.",
);
expect(result.text).toContain("- rem: writes reflection and pattern notes into DREAMS.md.");
});
it("persists global enablement under plugins.entries.memory-core.config.dreaming.enabled", async () => {
@@ -98,6 +97,7 @@ describe("memory-core /dreaming command", () => {
minScore: 0.9,
},
},
frequency: "0 */6 * * *",
},
},
},
@@ -110,31 +110,11 @@ describe("memory-core /dreaming command", () => {
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
enabled: false,
phases: {
deep: {
minScore: 0.9,
},
},
frequency: "0 */6 * * *",
});
expect(result.text).toContain("Dreaming disabled.");
});
it("persists phase changes under plugins.entries.memory-core.config.dreaming.phases", async () => {
const { command, runtime, getRuntimeConfig } = createHarness();
const result = await command.handler(createCommandContext("disable rem"));
expect(runtime.config.writeConfigFile).toHaveBeenCalledTimes(1);
expect(resolveStoredDreaming(getRuntimeConfig())).toMatchObject({
phases: {
rem: {
enabled: false,
},
},
});
expect(result.text).toContain("REM phase disabled.");
});
it("returns status without mutating config", async () => {
const { command, runtime } = createHarness({
plugins: {
@@ -142,31 +122,25 @@ describe("memory-core /dreaming command", () => {
"memory-core": {
config: {
dreaming: {
timezone: "America/Los_Angeles",
storage: {
mode: "both",
separateReports: true,
},
phases: {
deep: {
recencyHalfLifeDays: 21,
maxAgeDays: 45,
},
},
frequency: "15 */8 * * *",
},
},
},
},
},
agents: {
defaults: {
userTimezone: "America/Los_Angeles",
},
},
});
const result = await command.handler(createCommandContext("status"));
expect(result.text).toContain("Dreaming status:");
expect(result.text).toContain("- enabled: on (America/Los_Angeles)");
expect(result.text).toContain("- storage: both + reports");
expect(result.text).toContain("recencyHalfLifeDays=21");
expect(result.text).toContain("maxAgeDays=45");
expect(result.text).toContain("- sweep cadence: 15 */8 * * *");
expect(result.text).toContain("- promotion policy: score>=0.8, recalls>=3, uniqueQueries>=3");
expect(runtime.config.writeConfigFile).not.toHaveBeenCalled();
});

View File

@@ -1,13 +1,7 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
resolveMemoryLightDreamingConfig,
resolveMemoryRemDreamingConfig,
resolveMemoryDreamingConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import { resolveShortTermPromotionDreamingConfig } from "./dreaming.js";
type DreamingPhaseName = "light" | "deep" | "rem";
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
@@ -15,17 +9,6 @@ function asRecord(value: unknown): Record<string, unknown> | null {
return value as Record<string, unknown>;
}
function normalizeDreamingPhase(value: unknown): DreamingPhaseName | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "light" || normalized === "deep" || normalized === "rem") {
return normalized;
}
return null;
}
function resolveMemoryCorePluginConfig(cfg: OpenClawConfig): Record<string, unknown> {
const entry = asRecord(cfg.plugins?.entries?.["memory-core"]);
return asRecord(entry?.config) ?? {};
@@ -56,52 +39,15 @@ function updateDreamingEnabledInConfig(cfg: OpenClawConfig, enabled: boolean): O
};
}
function updateDreamingPhaseEnabledInConfig(
cfg: OpenClawConfig,
phase: DreamingPhaseName,
enabled: boolean,
): OpenClawConfig {
const entries = { ...(cfg.plugins?.entries ?? {}) };
const existingEntry = asRecord(entries["memory-core"]) ?? {};
const existingConfig = asRecord(existingEntry.config) ?? {};
const existingSleep = asRecord(existingConfig.dreaming) ?? {};
const existingPhases = asRecord(existingSleep.phases) ?? {};
const existingPhase = asRecord(existingPhases[phase]) ?? {};
entries["memory-core"] = {
...existingEntry,
config: {
...existingConfig,
dreaming: {
...existingSleep,
phases: {
...existingPhases,
[phase]: {
...existingPhase,
enabled,
},
},
},
},
};
return {
...cfg,
plugins: {
...cfg.plugins,
entries,
},
};
}
function formatEnabled(value: boolean): string {
return value ? "on" : "off";
}
function formatPhaseGuide(): string {
return [
"- light: sorts recent memory traces into DREAMS.md.",
"- deep: promotes durable memories into MEMORY.md and handles recovery when memory is thin.",
"- rem: writes reflection and pattern notes into DREAMS.md.",
"- implementation detail: each sweep runs light -> REM -> deep.",
"- deep is the only stage that writes durable entries to MEMORY.md.",
"- DREAMS.md is for human-readable dreaming summaries and diary entries.",
].join("\n");
}
@@ -112,18 +58,13 @@ function formatStatus(cfg: OpenClawConfig): string {
cfg,
});
const deep = resolveShortTermPromotionDreamingConfig({ pluginConfig, cfg });
const light = resolveMemoryLightDreamingConfig({ pluginConfig, cfg });
const rem = resolveMemoryRemDreamingConfig({ pluginConfig, cfg });
const timezone = dreaming.timezone ? ` (${dreaming.timezone})` : "";
return [
"Dreaming status:",
`- enabled: ${formatEnabled(dreaming.enabled)}${timezone}`,
`- storage: ${dreaming.storage.mode}${dreaming.storage.separateReports ? " + reports" : ""}`,
`- verboseLogging: ${formatEnabled(dreaming.verboseLogging)}`,
`- light: ${formatEnabled(light.enabled)} · cadence=${light.enabled ? light.cron : "disabled"} · lookbackDays=${light.lookbackDays} · limit=${light.limit}`,
`- deep: ${formatEnabled(deep.enabled)} · cadence=${deep.enabled ? deep.cron : "disabled"} · limit=${deep.limit} · minScore=${deep.minScore} · minRecallCount=${deep.minRecallCount} · minUniqueQueries=${deep.minUniqueQueries} · recencyHalfLifeDays=${deep.recencyHalfLifeDays} · maxAgeDays=${deep.maxAgeDays ?? "none"}`,
`- rem: ${formatEnabled(rem.enabled)} · cadence=${rem.enabled ? rem.cron : "disabled"} · lookbackDays=${rem.lookbackDays} · limit=${rem.limit} · minPatternStrength=${rem.minPatternStrength}`,
`- sweep cadence: ${dreaming.frequency}`,
`- promotion policy: score>=${deep.minScore}, recalls>=${deep.minRecallCount}, uniqueQueries>=${deep.minUniqueQueries}`,
].join("\n");
}
@@ -131,8 +72,6 @@ function formatUsage(includeStatus: string): string {
return [
"Usage: /dreaming status",
"Usage: /dreaming on|off",
"Usage: /dreaming enable light|deep|rem",
"Usage: /dreaming disable light|deep|rem",
"",
includeStatus,
"",
@@ -144,11 +83,11 @@ function formatUsage(includeStatus: string): string {
export function registerDreamingCommand(api: OpenClawPluginApi): void {
api.registerCommand({
name: "dreaming",
description: "Configure memory dreaming phases and durable promotion behavior.",
description: "Enable or disable memory dreaming.",
acceptsArgs: true,
handler: async (ctx) => {
const args = ctx.args?.trim() ?? "";
const [firstToken = "", secondToken = ""] = args
const [firstToken = ""] = args
.split(/\s+/)
.filter(Boolean)
.map((token) => token.toLowerCase());
@@ -180,20 +119,6 @@ export function registerDreamingCommand(api: OpenClawPluginApi): void {
};
}
const phase = normalizeDreamingPhase(secondToken);
if ((firstToken === "enable" || firstToken === "disable") && phase) {
const enabled = firstToken === "enable";
const nextConfig = updateDreamingPhaseEnabledInConfig(currentConfig, phase, enabled);
await api.runtime.config.writeConfigFile(nextConfig);
return {
text: [
`${phase.toUpperCase()} phase ${enabled ? "enabled" : "disabled"}.`,
"",
formatStatus(nextConfig),
].join("\n"),
};
}
return { text: formatUsage(formatStatus(currentConfig)) };
},
});

View File

@@ -24,8 +24,6 @@ describe("dreaming markdown storage", () => {
workspaceDir,
phase: "light",
bodyLines: ["- Candidate: remember the API key is fake"],
nowMs: Date.parse("2026-04-05T10:00:00Z"),
timezone: "UTC",
storage: {
mode: "inline",
separateReports: false,
@@ -34,7 +32,7 @@ describe("dreaming markdown storage", () => {
expect(result.inlinePath).toBe(path.join(workspaceDir, "DREAMS.md"));
const content = await fs.readFile(result.inlinePath!, "utf-8");
expect(content).toContain("## 2026-04-05 - Light Sleep");
expect(content).toContain("## Light Sleep");
expect(content).toContain("- Candidate: remember the API key is fake");
});
@@ -45,8 +43,6 @@ describe("dreaming markdown storage", () => {
workspaceDir,
phase: "light",
bodyLines: ["- Candidate: first block"],
nowMs: Date.parse("2026-04-05T10:00:00Z"),
timezone: "UTC",
storage: {
mode: "inline",
separateReports: false,
@@ -56,8 +52,6 @@ describe("dreaming markdown storage", () => {
workspaceDir,
phase: "rem",
bodyLines: ["- Theme: `focus` kept surfacing."],
nowMs: Date.parse("2026-04-05T11:00:00Z"),
timezone: "UTC",
storage: {
mode: "inline",
separateReports: false,
@@ -66,76 +60,31 @@ describe("dreaming markdown storage", () => {
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
const content = await fs.readFile(dreamsPath, "utf-8");
expect(content).toContain("## 2026-04-05 - Light Sleep");
expect(content).toContain("## 2026-04-05 - REM Sleep");
expect(content).toContain("## Light Sleep");
expect(content).toContain("## REM Sleep");
expect(content).toContain("- Candidate: first block");
expect(content).toContain("- Theme: `focus` kept surfacing.");
});
it("preserves prior days when writing later inline dreaming output", async () => {
it("reuses existing lowercase dreams.md when present", async () => {
const workspaceDir = await createTempWorkspace();
const lowercasePath = path.join(workspaceDir, "dreams.md");
await fs.writeFile(lowercasePath, "# Scratch\n\n", "utf-8");
await writeDailyDreamingPhaseBlock({
workspaceDir,
phase: "light",
bodyLines: ["- Candidate: day one"],
nowMs: Date.parse("2026-04-05T10:00:00Z"),
timezone: "UTC",
storage: {
mode: "inline",
separateReports: false,
},
});
await writeDailyDreamingPhaseBlock({
workspaceDir,
phase: "light",
bodyLines: ["- Candidate: day two"],
nowMs: Date.parse("2026-04-06T10:00:00Z"),
timezone: "UTC",
storage: {
mode: "inline",
separateReports: false,
},
});
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("## 2026-04-05 - Light Sleep");
expect(content).toContain("## 2026-04-06 - Light Sleep");
expect(content).toContain("- Candidate: day one");
expect(content).toContain("- Candidate: day two");
});
it("replaces the same day and phase block instead of appending duplicates", async () => {
const workspaceDir = await createTempWorkspace();
await writeDailyDreamingPhaseBlock({
const result = await writeDailyDreamingPhaseBlock({
workspaceDir,
phase: "rem",
bodyLines: ["- Theme: initial pass"],
nowMs: Date.parse("2026-04-05T10:00:00Z"),
timezone: "UTC",
storage: {
mode: "inline",
separateReports: false,
},
});
await writeDailyDreamingPhaseBlock({
workspaceDir,
phase: "rem",
bodyLines: ["- Theme: refreshed pass"],
nowMs: Date.parse("2026-04-05T14:00:00Z"),
timezone: "UTC",
bodyLines: ["- Theme: `glacier` kept surfacing."],
storage: {
mode: "inline",
separateReports: false,
},
});
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("## 2026-04-05 - REM Sleep");
expect(content).toContain("- Theme: refreshed pass");
expect(content).not.toContain("- Theme: initial pass");
expect(content.match(/## 2026-04-05 - REM Sleep/g)).toHaveLength(1);
expect(result.inlinePath).toBe(lowercasePath);
const content = await fs.readFile(lowercasePath, "utf-8");
expect(content).toContain("## REM Sleep");
expect(content).toContain("- Theme: `glacier` kept surfacing.");
});
it("still writes deep reports to the per-phase report directory", async () => {
@@ -156,5 +105,10 @@ describe("dreaming markdown storage", () => {
const content = await fs.readFile(reportPath!, "utf-8");
expect(content).toContain("# Deep Sleep");
expect(content).toContain("- Promoted: durable preference");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
const dreamsContent = await fs.readFile(dreamsPath, "utf-8");
expect(dreamsContent).toContain("## Deep Sleep");
expect(dreamsContent).toContain("- Promoted: durable preference");
});
});

View File

@@ -6,24 +6,28 @@ import {
type MemoryDreamingStorageConfig,
} from "openclaw/plugin-sdk/memory-core-host-status";
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
light: "## Light Sleep",
rem: "## REM Sleep",
};
const DEEP_PHASE_HEADING = "## Deep Sleep";
const DAILY_PHASE_LABELS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
light: "light",
rem: "rem",
};
const DREAMS_FILENAME = "DREAMS.md";
const PRIMARY_DREAMS_FILENAME = "DREAMS.md";
const DREAMS_FILENAME_ALIASES = [PRIMARY_DREAMS_FILENAME, "dreams.md"] as const;
function resolvePhaseMarkers(
phase: Exclude<MemoryDreamingPhaseName, "deep">,
isoDay: string,
): {
function resolvePhaseMarkers(phase: Exclude<MemoryDreamingPhaseName, "deep">): {
start: string;
end: string;
} {
const label = DAILY_PHASE_LABELS[phase];
return {
start: `<!-- openclaw:dreaming:${isoDay}:${label}:start -->`,
end: `<!-- openclaw:dreaming:${isoDay}:${label}:end -->`,
start: `<!-- openclaw:dreaming:${label}:start -->`,
end: `<!-- openclaw:dreaming:${label}:end -->`,
};
}
@@ -57,15 +61,42 @@ function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function resolveDreamsPath(workspaceDir: string): string {
return path.join(workspaceDir, DREAMS_FILENAME);
async function resolveDreamsPath(workspaceDir: string): Promise<string> {
for (const candidate of DREAMS_FILENAME_ALIASES) {
const target = path.join(workspaceDir, candidate);
try {
await fs.access(target);
return target;
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw err;
}
}
}
return path.join(workspaceDir, PRIMARY_DREAMS_FILENAME);
}
function resolveDreamsBlockHeading(
phase: Exclude<MemoryDreamingPhaseName, "deep">,
isoDay: string,
): string {
return `## ${isoDay} - ${phase === "light" ? "Light Sleep" : "REM Sleep"}`;
async function writeInlineDeepDreamingBlock(params: {
workspaceDir: string;
body: string;
}): Promise<string> {
const inlinePath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(inlinePath), { recursive: true });
const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
const updated = replaceManagedBlock({
original,
heading: DEEP_PHASE_HEADING,
startMarker: "<!-- openclaw:dreaming:deep:start -->",
endMarker: "<!-- openclaw:dreaming:deep:end -->",
body: params.body,
});
await fs.writeFile(inlinePath, withTrailingNewline(updated), "utf-8");
return inlinePath;
}
function resolveSeparateReportPath(
@@ -95,23 +126,23 @@ export async function writeDailyDreamingPhaseBlock(params: {
storage: MemoryDreamingStorageConfig;
}): Promise<{ inlinePath?: string; reportPath?: string }> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const isoDay = formatMemoryDreamingDay(nowMs, params.timezone);
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No notable updates.";
let inlinePath: string | undefined;
let reportPath: string | undefined;
if (shouldWriteInline(params.storage)) {
inlinePath = resolveDreamsPath(params.workspaceDir);
inlinePath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(inlinePath), { recursive: true });
const original = await fs.readFile(inlinePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
const markers = resolvePhaseMarkers(params.phase, isoDay);
const markers = resolvePhaseMarkers(params.phase);
const updated = replaceManagedBlock({
original,
heading: resolveDreamsBlockHeading(params.phase, isoDay),
heading: DAILY_PHASE_HEADINGS[params.phase],
startMarker: markers.start,
endMarker: markers.end,
body,
@@ -149,13 +180,19 @@ export async function writeDeepDreamingReport(params: {
timezone?: string;
storage: MemoryDreamingStorageConfig;
}): Promise<string | undefined> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
await writeInlineDeepDreamingBlock({
workspaceDir: params.workspaceDir,
body,
});
if (!shouldWriteSeparate(params.storage)) {
return undefined;
}
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
await fs.mkdir(path.dirname(reportPath), { recursive: true });
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
return reportPath;
}

View File

@@ -0,0 +1,340 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendNarrativeEntry,
buildDiaryEntry,
buildNarrativePrompt,
extractNarrativeText,
formatNarrativeDate,
generateAndAppendDreamNarrative,
type NarrativePhaseData,
} from "./dreaming-narrative.js";
const tempDirs: string[] = [];
async function createTempWorkspace(): Promise<string> {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dreaming-narrative-"));
tempDirs.push(workspaceDir);
return workspaceDir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
describe("buildNarrativePrompt", () => {
it("builds a prompt from snippets only", () => {
const data: NarrativePhaseData = {
phase: "light",
snippets: ["user prefers dark mode", "API key rotation scheduled"],
};
const prompt = buildNarrativePrompt(data);
expect(prompt).toContain("user prefers dark mode");
expect(prompt).toContain("API key rotation scheduled");
expect(prompt).not.toContain("Recurring themes");
});
it("includes themes when provided", () => {
const data: NarrativePhaseData = {
phase: "rem",
snippets: ["config migration path"],
themes: ["infrastructure", "deployment"],
};
const prompt = buildNarrativePrompt(data);
expect(prompt).toContain("Recurring themes");
expect(prompt).toContain("infrastructure");
expect(prompt).toContain("deployment");
});
it("includes promotions for deep phase", () => {
const data: NarrativePhaseData = {
phase: "deep",
snippets: ["trading bot uses bracket orders"],
promotions: ["always use stop-loss on options trades"],
};
const prompt = buildNarrativePrompt(data);
expect(prompt).toContain("crystallized");
expect(prompt).toContain("always use stop-loss on options trades");
});
it("caps snippets at 12", () => {
const snippets = Array.from({ length: 20 }, (_, i) => `snippet-${i}`);
const prompt = buildNarrativePrompt({ phase: "light", snippets });
expect(prompt).toContain("snippet-11");
expect(prompt).not.toContain("snippet-12");
});
});
describe("extractNarrativeText", () => {
it("extracts string content from assistant message", () => {
const messages = [
{ role: "user", content: "hello" },
{ role: "assistant", content: "The workspace hummed quietly." },
];
expect(extractNarrativeText(messages)).toBe("The workspace hummed quietly.");
});
it("extracts from content array with text blocks", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "First paragraph." },
{ type: "text", text: "Second paragraph." },
],
},
];
expect(extractNarrativeText(messages)).toBe("First paragraph.\nSecond paragraph.");
});
it("returns null when no assistant message exists", () => {
const messages = [{ role: "user", content: "hello" }];
expect(extractNarrativeText(messages)).toBeNull();
});
it("returns null for empty assistant content", () => {
const messages = [{ role: "assistant", content: " " }];
expect(extractNarrativeText(messages)).toBeNull();
});
it("picks the last assistant message", () => {
const messages = [
{ role: "assistant", content: "First response." },
{ role: "user", content: "more" },
{ role: "assistant", content: "Final response." },
];
expect(extractNarrativeText(messages)).toBe("Final response.");
});
});
describe("formatNarrativeDate", () => {
it("formats a UTC date", () => {
const date = formatNarrativeDate(Date.parse("2026-04-05T03:00:00Z"), "UTC");
expect(date).toContain("April");
expect(date).toContain("2026");
expect(date).toContain("3:00");
});
});
describe("buildDiaryEntry", () => {
it("formats narrative with date and separators", () => {
const entry = buildDiaryEntry("The code drifted gently.", "April 5, 2026, 3:00 AM");
expect(entry).toContain("---");
expect(entry).toContain("*April 5, 2026, 3:00 AM*");
expect(entry).toContain("The code drifted gently.");
});
});
describe("appendNarrativeEntry", () => {
it("creates DREAMS.md with diary header on fresh workspace", async () => {
const workspaceDir = await createTempWorkspace();
const dreamsPath = await appendNarrativeEntry({
workspaceDir,
narrative: "Fragments of authentication logic kept surfacing.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
expect(dreamsPath).toBe(path.join(workspaceDir, "DREAMS.md"));
const content = await fs.readFile(dreamsPath, "utf-8");
expect(content).toContain("# Dream Diary");
expect(content).toContain("Fragments of authentication logic kept surfacing.");
expect(content).toContain("<!-- openclaw:dreaming:diary:start -->");
expect(content).toContain("<!-- openclaw:dreaming:diary:end -->");
});
it("appends a second entry within the diary markers", async () => {
const workspaceDir = await createTempWorkspace();
await appendNarrativeEntry({
workspaceDir,
narrative: "First dream.",
nowMs: Date.parse("2026-04-04T03:00:00Z"),
timezone: "UTC",
});
await appendNarrativeEntry({
workspaceDir,
narrative: "Second dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("First dream.");
expect(content).toContain("Second dream.");
// Both entries should be between start and end markers.
const start = content.indexOf("<!-- openclaw:dreaming:diary:start -->");
const end = content.indexOf("<!-- openclaw:dreaming:diary:end -->");
const firstIdx = content.indexOf("First dream.");
const secondIdx = content.indexOf("Second dream.");
expect(firstIdx).toBeGreaterThan(start);
expect(secondIdx).toBeGreaterThan(firstIdx);
expect(secondIdx).toBeLessThan(end);
});
it("prepends diary before existing managed blocks", async () => {
const workspaceDir = await createTempWorkspace();
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(
dreamsPath,
"## Light Sleep\n<!-- openclaw:dreaming:light:start -->\n- Candidate: test\n<!-- openclaw:dreaming:light:end -->\n",
"utf-8",
);
await appendNarrativeEntry({
workspaceDir,
narrative: "The workspace was quiet tonight.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
const content = await fs.readFile(dreamsPath, "utf-8");
const diaryIdx = content.indexOf("# Dream Diary");
const lightIdx = content.indexOf("## Light Sleep");
// Diary should come before the managed block.
expect(diaryIdx).toBeLessThan(lightIdx);
expect(content).toContain("The workspace was quiet tonight.");
});
it("reuses existing dreams file when present", async () => {
const workspaceDir = await createTempWorkspace();
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
await fs.writeFile(dreamsPath, "# Existing\n", "utf-8");
const result = await appendNarrativeEntry({
workspaceDir,
narrative: "Appended dream.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
expect(result).toBe(dreamsPath);
const content = await fs.readFile(dreamsPath, "utf-8");
expect(content).toContain("Appended dream.");
// Original content should still be there, after the diary.
expect(content).toContain("# Existing");
});
});
describe("generateAndAppendDreamNarrative", () => {
function createMockSubagent(responseText: string) {
return {
run: vi.fn().mockResolvedValue({ runId: "run-123" }),
waitForRun: vi.fn().mockResolvedValue({ status: "ok" }),
getSessionMessages: vi.fn().mockResolvedValue({
messages: [
{ role: "user", content: "prompt" },
{ role: "assistant", content: responseText },
],
}),
deleteSession: vi.fn().mockResolvedValue(undefined),
};
}
function createMockLogger() {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
}
it("generates narrative and writes diary entry", async () => {
const workspaceDir = await createTempWorkspace();
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: {
phase: "light",
snippets: ["API endpoints need authentication"],
},
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
logger,
});
expect(subagent.run).toHaveBeenCalledOnce();
expect(subagent.run.mock.calls[0][0]).toMatchObject({
deliver: false,
});
expect(subagent.waitForRun).toHaveBeenCalledOnce();
expect(subagent.deleteSession).toHaveBeenCalledOnce();
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("The repository whispered of forgotten endpoints.");
expect(logger.info).toHaveBeenCalled();
});
it("skips narrative when no snippets are available", async () => {
const workspaceDir = await createTempWorkspace();
const subagent = createMockSubagent("Should not appear.");
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "light", snippets: [] },
logger,
});
expect(subagent.run).not.toHaveBeenCalled();
const exists = await fs
.access(path.join(workspaceDir, "DREAMS.md"))
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
it("handles subagent timeout gracefully", async () => {
const workspaceDir = await createTempWorkspace();
const subagent = createMockSubagent("");
subagent.waitForRun.mockResolvedValue({ status: "timeout" });
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "deep", snippets: ["some memory"] },
logger,
});
// Should not throw, should warn.
expect(logger.warn).toHaveBeenCalled();
const exists = await fs
.access(path.join(workspaceDir, "DREAMS.md"))
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
it("handles subagent error gracefully", async () => {
const workspaceDir = await createTempWorkspace();
const subagent = createMockSubagent("");
subagent.run.mockRejectedValue(new Error("connection failed"));
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "rem", snippets: ["pattern surfaced"] },
logger,
});
// Should not throw.
expect(logger.warn).toHaveBeenCalled();
});
it("cleans up session even on failure", async () => {
const workspaceDir = await createTempWorkspace();
const subagent = createMockSubagent("");
subagent.getSessionMessages.mockRejectedValue(new Error("fetch failed"));
const logger = createMockLogger();
await generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: { phase: "light", snippets: ["memory fragment"] },
logger,
});
expect(subagent.deleteSession).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,299 @@
import fs from "node:fs/promises";
import path from "node:path";
// ── Types ──────────────────────────────────────────────────────────────
type SubagentSurface = {
run: (params: {
sessionKey: string;
message: string;
extraSystemPrompt?: string;
deliver?: boolean;
}) => Promise<{ runId: string }>;
waitForRun: (params: {
runId: string;
timeoutMs?: number;
}) => Promise<{ status: string; error?: string }>;
getSessionMessages: (params: {
sessionKey: string;
limit?: number;
}) => Promise<{ messages: unknown[] }>;
deleteSession: (params: { sessionKey: string }) => Promise<void>;
};
export type NarrativePhaseData = {
phase: "light" | "deep" | "rem";
/** Short memory snippets the phase processed. */
snippets: string[];
/** Concept tags / themes that surfaced (REM and light). */
themes?: string[];
/** Snippets that were promoted to durable memory (deep). */
promotions?: string[];
};
type Logger = {
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
};
// ── Constants ──────────────────────────────────────────────────────────
const NARRATIVE_SYSTEM_PROMPT = [
"You are keeping a dream diary. Write a single entry in first person.",
"",
"Voice & tone:",
"- You are a curious, gentle, slightly whimsical mind reflecting on the day.",
"- Write like a poet who happens to be a programmer — sensory, warm, occasionally funny.",
"- Mix the technical and the tender: code and constellations, APIs and afternoon light.",
"- Let the fragments surprise you into unexpected connections and small epiphanies.",
"",
"What you might include (vary each entry, never all at once):",
"- A tiny poem or haiku woven naturally into the prose",
"- A small sketch described in words — a doodle in the margin of the diary",
"- A quiet rumination or philosophical aside",
"- Sensory details: the hum of a server, the color of a sunset in hex, rain on a window",
"- Gentle humor or playful wordplay",
"- An observation that connects two distant memories in an unexpected way",
"",
"Rules:",
"- Draw from the memory fragments provided — weave them into the entry.",
'- Never say "I\'m dreaming", "in my dream", "as I dream", or any meta-commentary about dreaming.',
'- Never mention "AI", "agent", "LLM", "model", "language model", or any technical self-reference.',
"- Do NOT use markdown headers, bullet points, or any formatting — just flowing prose.",
"- Keep it between 80-180 words. Quality over quantity.",
"- Output ONLY the diary entry. No preamble, no sign-off, no commentary.",
].join("\n");
const NARRATIVE_TIMEOUT_MS = 60_000;
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
// ── Prompt building ────────────────────────────────────────────────────
export function buildNarrativePrompt(data: NarrativePhaseData): string {
const lines: string[] = [];
lines.push("Write a dream diary entry from these memory fragments:\n");
for (const snippet of data.snippets.slice(0, 12)) {
lines.push(`- ${snippet}`);
}
if (data.themes?.length) {
lines.push("\nRecurring themes:");
for (const theme of data.themes.slice(0, 6)) {
lines.push(`- ${theme}`);
}
}
if (data.promotions?.length) {
lines.push("\nMemories that crystallized into something lasting:");
for (const promo of data.promotions.slice(0, 5)) {
lines.push(`- ${promo}`);
}
}
return lines.join("\n");
}
// ── Message extraction ─────────────────────────────────────────────────
export function extractNarrativeText(messages: unknown[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
continue;
}
const record = msg as Record<string, unknown>;
if (record.role !== "assistant") {
continue;
}
const content = record.content;
if (typeof content === "string" && content.trim().length > 0) {
return content.trim();
}
if (Array.isArray(content)) {
const text = content
.filter(
(part: unknown) =>
part &&
typeof part === "object" &&
!Array.isArray(part) &&
(part as Record<string, unknown>).type === "text" &&
typeof (part as Record<string, unknown>).text === "string",
)
.map((part) => (part as { text: string }).text)
.join("\n")
.trim();
if (text.length > 0) {
return text;
}
}
}
return null;
}
// ── Date formatting ────────────────────────────────────────────────────
export function formatNarrativeDate(epochMs: number, timezone?: string): string {
const opts: Intl.DateTimeFormatOptions = {
timeZone: timezone ?? "UTC",
year: "numeric",
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
};
return new Intl.DateTimeFormat("en-US", opts).format(new Date(epochMs));
}
// ── DREAMS.md file I/O ─────────────────────────────────────────────────
async function resolveDreamsPath(workspaceDir: string): Promise<string> {
for (const name of DREAMS_FILENAMES) {
const target = path.join(workspaceDir, name);
try {
await fs.access(target);
return target;
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw err;
}
}
}
return path.join(workspaceDir, DREAMS_FILENAMES[0]);
}
export function buildDiaryEntry(narrative: string, dateStr: string): string {
return `\n---\n\n*${dateStr}*\n\n${narrative}\n`;
}
export async function appendNarrativeEntry(params: {
workspaceDir: string;
narrative: string;
nowMs: number;
timezone?: string;
}): Promise<string> {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
await fs.mkdir(path.dirname(dreamsPath), { recursive: true });
const dateStr = formatNarrativeDate(params.nowMs, params.timezone);
const entry = buildDiaryEntry(params.narrative, dateStr);
let existing = "";
try {
existing = await fs.readFile(dreamsPath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code !== "ENOENT") {
throw err;
}
}
let updated: string;
if (existing.includes(DIARY_START_MARKER) && existing.includes(DIARY_END_MARKER)) {
// Append entry before end marker.
const endIdx = existing.lastIndexOf(DIARY_END_MARKER);
updated = existing.slice(0, endIdx) + entry + "\n" + existing.slice(endIdx);
} else if (existing.includes(DIARY_START_MARKER)) {
// Start marker without end — append entry and add end marker.
const startIdx = existing.indexOf(DIARY_START_MARKER) + DIARY_START_MARKER.length;
updated =
existing.slice(0, startIdx) +
entry +
"\n" +
DIARY_END_MARKER +
"\n" +
existing.slice(startIdx);
} else {
// No diary section yet — create one.
const diarySection = `# Dream Diary\n\n${DIARY_START_MARKER}${entry}\n${DIARY_END_MARKER}\n`;
if (existing.trim().length === 0) {
updated = diarySection;
} else {
// Prepend diary before any existing managed blocks.
updated = diarySection + "\n" + existing;
}
}
await fs.writeFile(dreamsPath, updated.endsWith("\n") ? updated : `${updated}\n`, "utf-8");
return dreamsPath;
}
// ── Orchestrator ───────────────────────────────────────────────────────
export async function generateAndAppendDreamNarrative(params: {
subagent: SubagentSurface;
workspaceDir: string;
data: NarrativePhaseData;
nowMs?: number;
timezone?: string;
logger: Logger;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
if (params.data.snippets.length === 0 && !params.data.promotions?.length) {
return;
}
const sessionKey = `dreaming-narrative-${params.data.phase}-${nowMs}`;
const message = buildNarrativePrompt(params.data);
try {
const { runId } = await params.subagent.run({
sessionKey,
message,
extraSystemPrompt: NARRATIVE_SYSTEM_PROMPT,
deliver: false,
});
const result = await params.subagent.waitForRun({
runId,
timeoutMs: NARRATIVE_TIMEOUT_MS,
});
if (result.status !== "ok") {
params.logger.warn(
`memory-core: narrative generation ended with status=${result.status} for ${params.data.phase} phase.`,
);
return;
}
const { messages } = await params.subagent.getSessionMessages({
sessionKey,
limit: 5,
});
const narrative = extractNarrativeText(messages);
if (!narrative) {
params.logger.warn(
`memory-core: narrative generation produced no text for ${params.data.phase} phase.`,
);
return;
}
await appendNarrativeEntry({
workspaceDir: params.workspaceDir,
narrative,
nowMs,
timezone: params.timezone,
});
params.logger.info(
`memory-core: dream diary entry written for ${params.data.phase} phase [workspace=${params.workspaceDir}].`,
);
} catch (err) {
// Narrative generation is best-effort — never fail the parent phase.
params.logger.warn(
`memory-core: narrative generation failed for ${params.data.phase} phase: ${err instanceof Error ? err.message : String(err)}`,
);
} finally {
// Clean up the transient session.
try {
await params.subagent.deleteSession({ sessionKey });
} catch {
// Ignore cleanup failures.
}
}
}

View File

@@ -0,0 +1,268 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerMemoryDreamingPhases } from "./dreaming-phases.js";
import {
rankShortTermPromotionCandidates,
recordShortTermRecalls,
resolveShortTermPhaseSignalStorePath,
} from "./short-term-promotion.js";
const tempDirs: string[] = [];
async function createTempWorkspace(): Promise<string> {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dreaming-phases-"));
tempDirs.push(workspaceDir);
return workspaceDir;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
function createHarness(config: OpenClawConfig) {
let beforeAgentReply:
| ((
event: { cleanedBody: string },
ctx: { trigger?: string; workspaceDir?: string },
) => Promise<unknown>)
| undefined;
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const api = {
config,
pluginConfig: {},
logger,
registerHook: vi.fn(),
on: vi.fn((name: string, handler: unknown) => {
if (name === "before_agent_reply") {
beforeAgentReply = handler as typeof beforeAgentReply;
}
}),
} as unknown as OpenClawPluginApi;
registerMemoryDreamingPhases(api);
if (!beforeAgentReply) {
throw new Error("before_agent_reply hook not registered");
}
return { beforeAgentReply, logger };
}
describe("memory-core dreaming phases", () => {
it("checkpoints daily ingestion and skips unchanged daily files", async () => {
const workspaceDir = await createTempWorkspace();
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
const dailyPath = path.join(workspaceDir, "memory", "2026-04-05.md");
await fs.writeFile(
dailyPath,
["# 2026-04-05", "", "- Move backups to S3 Glacier."].join("\n"),
"utf-8",
);
const { beforeAgentReply } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 2,
},
},
},
},
},
},
},
});
const readSpy = vi.spyOn(fs, "readFile");
try {
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
} finally {
readSpy.mockRestore();
}
const dailyReadCount = readSpy.mock.calls.filter(
([target]) => String(target) === dailyPath,
).length;
expect(dailyReadCount).toBe(1);
await expect(
fs.access(path.join(workspaceDir, "memory", ".dreams", "daily-ingestion.json")),
).resolves.toBeUndefined();
});
it("ingests recent daily memory files even before recall traffic exists", async () => {
const workspaceDir = await createTempWorkspace();
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "memory", "2026-04-05.md"),
["# 2026-04-05", "", "- Move backups to S3 Glacier.", "- Keep retention at 365 days."].join(
"\n",
),
"utf-8",
);
const before = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
});
expect(before).toHaveLength(0);
const { beforeAgentReply } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 20,
lookbackDays: 2,
},
},
},
},
},
},
},
});
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
const after = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse("2026-04-05T10:05:00.000Z"),
});
expect(after.length).toBeGreaterThan(0);
expect(after.some((candidate) => (candidate.dailyCount ?? 0) > 0)).toBe(true);
});
it("records light/rem signals that reinforce deep promotion ranking", async () => {
const workspaceDir = await createTempWorkspace();
const nowMs = Date.parse("2026-04-05T10:00:00.000Z");
await recordShortTermRecalls({
workspaceDir,
query: "glacier backup",
nowMs,
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.92,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
await recordShortTermRecalls({
workspaceDir,
query: "cold storage retention",
nowMs,
results: [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const baseline = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs,
});
expect(baseline).toHaveLength(1);
const baselineScore = baseline[0]!.score;
const { beforeAgentReply } = createHarness({
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
phases: {
light: {
enabled: true,
limit: 10,
lookbackDays: 7,
},
rem: {
enabled: true,
limit: 10,
lookbackDays: 7,
minPatternStrength: 0,
},
},
},
},
},
},
},
});
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_light_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
await beforeAgentReply(
{ cleanedBody: "__openclaw_memory_core_rem_sleep__" },
{ trigger: "heartbeat", workspaceDir },
);
const reinforced = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs,
});
expect(reinforced).toHaveLength(1);
expect(reinforced[0]!.score).toBeGreaterThan(baselineScore);
const phaseSignalPath = resolveShortTermPhaseSignalStorePath(workspaceDir);
const phaseSignalStore = JSON.parse(await fs.readFile(phaseSignalPath, "utf-8")) as {
entries: Record<string, { lightHits: number; remHits: number }>;
};
expect(phaseSignalStore.entries[reinforced[0]!.key]).toMatchObject({
lightHits: 1,
remHits: 1,
});
});
});

View File

@@ -1,4 +1,8 @@
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
import {
resolveMemoryCorePluginConfig,
resolveMemoryLightDreamingConfig,
@@ -9,7 +13,13 @@ import {
type MemoryDreamingPhaseName,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
import { readShortTermRecallEntries, type ShortTermRecallEntry } from "./short-term-promotion.js";
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
import {
readShortTermRecallEntries,
recordShortTermRecalls,
recordDreamingPhaseSignals,
type ShortTermRecallEntry,
} from "./short-term-promotion.js";
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
@@ -68,6 +78,11 @@ const LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
const DAILY_MEMORY_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})\.md$/;
const DAILY_INGESTION_STATE_RELATIVE_PATH = path.join("memory", ".dreams", "daily-ingestion.json");
const DAILY_INGESTION_SCORE = 0.62;
const DAILY_INGESTION_MAX_SNIPPET_CHARS = 280;
const DAILY_INGESTION_MIN_SNIPPET_CHARS = 8;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -304,6 +319,279 @@ function calculateLookbackCutoffMs(nowMs: number, lookbackDays: number): number
return nowMs - Math.max(0, lookbackDays) * 24 * 60 * 60 * 1000;
}
function isDayWithinLookback(day: string, cutoffMs: number): boolean {
const dayMs = Date.parse(`${day}T23:59:59.999Z`);
return Number.isFinite(dayMs) && dayMs >= cutoffMs;
}
function normalizeDailySnippet(line: string): string | null {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
if (trimmed.startsWith("#") || trimmed.startsWith("<!--")) {
return null;
}
const withoutListMarker = trimmed
.replace(/^\d+\.\s+/, "")
.replace(/^[-*+]\s+/, "")
.trim();
if (withoutListMarker.length < DAILY_INGESTION_MIN_SNIPPET_CHARS) {
return null;
}
return withoutListMarker.slice(0, DAILY_INGESTION_MAX_SNIPPET_CHARS).replace(/\s+/g, " ");
}
function entryWithinLookback(entry: ShortTermRecallEntry, cutoffMs: number): boolean {
const byDay = (entry.recallDays ?? []).some((day) => isDayWithinLookback(day, cutoffMs));
if (byDay) {
return true;
}
const lastRecalledAtMs = Date.parse(entry.lastRecalledAt);
return Number.isFinite(lastRecalledAtMs) && lastRecalledAtMs >= cutoffMs;
}
type DailyIngestionBatch = {
day: string;
results: MemorySearchResult[];
};
type DailyIngestionFileState = {
mtimeMs: number;
size: number;
};
type DailyIngestionState = {
version: 1;
files: Record<string, DailyIngestionFileState>;
};
function resolveDailyIngestionStatePath(workspaceDir: string): string {
return path.join(workspaceDir, DAILY_INGESTION_STATE_RELATIVE_PATH);
}
function normalizeDailyIngestionState(raw: unknown): DailyIngestionState {
const record = asRecord(raw);
const filesRaw = asRecord(record?.files);
if (!filesRaw) {
return {
version: 1,
files: {},
};
}
const files: Record<string, DailyIngestionFileState> = {};
for (const [key, value] of Object.entries(filesRaw)) {
const file = asRecord(value);
if (!file || typeof key !== "string" || key.trim().length === 0) {
continue;
}
const mtimeMs = Number(file.mtimeMs);
const size = Number(file.size);
if (!Number.isFinite(mtimeMs) || mtimeMs < 0 || !Number.isFinite(size) || size < 0) {
continue;
}
files[key] = {
mtimeMs: Math.floor(mtimeMs),
size: Math.floor(size),
};
}
return {
version: 1,
files,
};
}
async function readDailyIngestionState(workspaceDir: string): Promise<DailyIngestionState> {
const statePath = resolveDailyIngestionStatePath(workspaceDir);
try {
const raw = await fs.readFile(statePath, "utf-8");
return normalizeDailyIngestionState(JSON.parse(raw) as unknown);
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code === "ENOENT" || err instanceof SyntaxError) {
return { version: 1, files: {} };
}
throw err;
}
}
async function writeDailyIngestionState(
workspaceDir: string,
state: DailyIngestionState,
): Promise<void> {
const statePath = resolveDailyIngestionStatePath(workspaceDir);
await fs.mkdir(path.dirname(statePath), { recursive: true });
const tmpPath = `${statePath}.${process.pid}.${Date.now()}.tmp`;
await fs.writeFile(tmpPath, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
await fs.rename(tmpPath, statePath);
}
type DailyIngestionCollectionResult = {
batches: DailyIngestionBatch[];
nextState: DailyIngestionState;
changed: boolean;
};
async function collectDailyIngestionBatches(params: {
workspaceDir: string;
lookbackDays: number;
limit: number;
nowMs: number;
state: DailyIngestionState;
}): Promise<DailyIngestionCollectionResult> {
const memoryDir = path.join(params.workspaceDir, "memory");
const cutoffMs = calculateLookbackCutoffMs(params.nowMs, params.lookbackDays);
const entries = await fs.readdir(memoryDir, { withFileTypes: true }).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return [] as Dirent[];
}
throw err;
});
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => {
const match = entry.name.match(DAILY_MEMORY_FILENAME_RE);
if (!match) {
return null;
}
const day = match[1]!;
if (!isDayWithinLookback(day, cutoffMs)) {
return null;
}
return { fileName: entry.name, day };
})
.filter((entry): entry is { fileName: string; day: string } => entry !== null)
.toSorted((a, b) => b.day.localeCompare(a.day));
const batches: DailyIngestionBatch[] = [];
const nextFiles: Record<string, DailyIngestionFileState> = {};
let changed = false;
const totalCap = Math.max(20, params.limit * 4);
const perFileCap = Math.max(6, Math.ceil(totalCap / Math.max(1, Math.max(files.length, 1))));
let total = 0;
for (const file of files) {
const relativePath = `memory/${file.fileName}`;
const filePath = path.join(memoryDir, file.fileName);
const stat = await fs.stat(filePath).catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return null;
}
throw err;
});
if (!stat) {
continue;
}
const fingerprint: DailyIngestionFileState = {
mtimeMs: Math.floor(Math.max(0, stat.mtimeMs)),
size: Math.floor(Math.max(0, stat.size)),
};
nextFiles[relativePath] = fingerprint;
const previous = params.state.files[relativePath];
const unchanged =
previous !== undefined &&
previous.mtimeMs === fingerprint.mtimeMs &&
previous.size === fingerprint.size;
if (!unchanged) {
changed = true;
} else {
continue;
}
const raw = await fs.readFile(filePath, "utf-8").catch((err: unknown) => {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;
});
if (!raw) {
continue;
}
const lines = raw.split(/\r?\n/);
const results: MemorySearchResult[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (typeof line !== "string") {
continue;
}
const snippet = normalizeDailySnippet(line);
if (!snippet) {
continue;
}
results.push({
path: relativePath,
startLine: index + 1,
endLine: index + 1,
score: DAILY_INGESTION_SCORE,
snippet,
source: "memory",
});
if (results.length >= perFileCap || total + results.length >= totalCap) {
break;
}
}
if (results.length === 0) {
continue;
}
batches.push({ day: file.day, results });
total += results.length;
if (total >= totalCap) {
break;
}
}
if (!changed) {
const previousKeys = Object.keys(params.state.files);
const nextKeys = Object.keys(nextFiles);
if (
previousKeys.length !== nextKeys.length ||
previousKeys.some((key) => !Object.hasOwn(nextFiles, key))
) {
changed = true;
}
}
return {
batches,
nextState: {
version: 1,
files: nextFiles,
},
changed,
};
}
async function ingestDailyMemorySignals(params: {
workspaceDir: string;
lookbackDays: number;
limit: number;
nowMs: number;
timezone?: string;
}): Promise<void> {
const state = await readDailyIngestionState(params.workspaceDir);
const collected = await collectDailyIngestionBatches({
workspaceDir: params.workspaceDir,
lookbackDays: params.lookbackDays,
limit: params.limit,
nowMs: params.nowMs,
state,
});
for (const batch of collected.batches) {
await recordShortTermRecalls({
workspaceDir: params.workspaceDir,
query: `__dreaming_daily__:${batch.day}`,
results: batch.results,
signalType: "daily",
dedupeByQueryPerDay: true,
dayBucket: batch.day,
nowMs: params.nowMs,
timezone: params.timezone,
});
}
if (collected.changed) {
await writeDailyIngestionState(params.workspaceDir, collected.nextState);
}
}
function entryAverageScore(entry: ShortTermRecallEntry): number {
return entry.recallCount > 0 ? Math.max(0, Math.min(1, entry.totalScore / entry.recallCount)) : 0;
}
@@ -380,16 +668,20 @@ function buildLightDreamingBody(entries: ShortTermRecallEntry[]): string[] {
return lines;
}
type RemTruthCandidate = {
type RemTruthSelection = {
key: string;
snippet: string;
confidence: number;
evidence: string;
};
type RemTruthCandidate = Omit<RemTruthSelection, "key">;
export type RemDreamingPreview = {
sourceEntryCount: number;
reflections: string[];
candidateTruths: RemTruthCandidate[];
candidateKeys: string[];
bodyLines: string[];
};
@@ -410,7 +702,7 @@ function calculateCandidateTruthConfidence(entry: ShortTermRecallEntry): number
function selectRemCandidateTruths(
entries: ShortTermRecallEntry[],
limit: number,
): RemTruthCandidate[] {
): RemTruthSelection[] {
if (limit <= 0) {
return [];
}
@@ -419,6 +711,7 @@ function selectRemCandidateTruths(
0.88,
)
.map((entry) => ({
key: entry.key,
snippet: entry.snippet || "(no snippet captured)",
confidence: calculateCandidateTruthConfidence(entry),
evidence: `${entry.path}:${entry.startLine}-${entry.endLine}`,
@@ -478,10 +771,16 @@ export function previewRemDreaming(params: {
minPatternStrength: number;
}): RemDreamingPreview {
const reflections = buildRemReflections(params.entries, params.limit, params.minPatternStrength);
const candidateTruths = selectRemCandidateTruths(
const candidateSelections = selectRemCandidateTruths(
params.entries,
Math.max(1, Math.min(3, params.limit)),
);
const candidateTruths = candidateSelections.map((entry) => ({
snippet: entry.snippet,
confidence: entry.confidence,
evidence: entry.evidence,
}));
const candidateKeys = [...new Set(candidateSelections.map((entry) => entry.key))];
const bodyLines = [
"### Reflections",
...reflections,
@@ -498,6 +797,7 @@ export function previewRemDreaming(params: {
sourceEntryCount: params.entries.length,
reflections,
candidateTruths,
candidateKeys,
bodyLines,
};
}
@@ -509,13 +809,21 @@ async function runLightDreaming(params: {
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
};
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const cutoffMs = calculateLookbackCutoffMs(nowMs, params.config.lookbackDays);
await ingestDailyMemorySignals({
workspaceDir: params.workspaceDir,
lookbackDays: params.config.lookbackDays,
limit: params.config.limit,
nowMs,
timezone: params.config.timezone,
});
const entries = dedupeEntries(
(await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs }))
.filter((entry) => Date.parse(entry.lastRecalledAt) >= cutoffMs)
.filter((entry) => entryWithinLookback(entry, cutoffMs))
.toSorted((a, b) => {
const byTime = Date.parse(b.lastRecalledAt) - Date.parse(a.lastRecalledAt);
if (byTime !== 0) {
@@ -526,7 +834,8 @@ async function runLightDreaming(params: {
.slice(0, params.config.limit),
params.config.dedupeSimilarity,
);
const bodyLines = buildLightDreamingBody(entries.slice(0, params.config.limit));
const capped = entries.slice(0, params.config.limit);
const bodyLines = buildLightDreamingBody(capped);
await writeDailyDreamingPhaseBlock({
workspaceDir: params.workspaceDir,
phase: "light",
@@ -535,11 +844,34 @@ async function runLightDreaming(params: {
timezone: params.config.timezone,
storage: params.config.storage,
});
await recordDreamingPhaseSignals({
workspaceDir: params.workspaceDir,
phase: "light",
keys: capped.map((entry) => entry.key),
nowMs,
});
if (params.config.enabled && entries.length > 0 && params.config.storage.mode !== "separate") {
params.logger.info(
`memory-core: light dreaming staged ${Math.min(entries.length, params.config.limit)} candidate(s) [workspace=${params.workspaceDir}].`,
);
}
// Generate dream diary narrative from the staged entries.
if (params.subagent && capped.length > 0) {
const themes = [...new Set(capped.flatMap((e) => e.conceptTags).filter(Boolean))];
const data: NarrativePhaseData = {
phase: "light",
snippets: capped.map((e) => e.snippet).filter(Boolean),
...(themes.length > 0 ? { themes } : {}),
};
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
});
}
}
async function runRemDreaming(params: {
@@ -549,13 +881,21 @@ async function runRemDreaming(params: {
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
};
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
nowMs?: number;
}): Promise<void> {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const cutoffMs = calculateLookbackCutoffMs(nowMs, params.config.lookbackDays);
await ingestDailyMemorySignals({
workspaceDir: params.workspaceDir,
lookbackDays: params.config.lookbackDays,
limit: params.config.limit,
nowMs,
timezone: params.config.timezone,
});
const entries = (
await readShortTermRecallEntries({ workspaceDir: params.workspaceDir, nowMs })
).filter((entry) => Date.parse(entry.lastRecalledAt) >= cutoffMs);
).filter((entry) => entryWithinLookback(entry, cutoffMs));
const preview = previewRemDreaming({
entries,
limit: params.config.limit,
@@ -569,11 +909,80 @@ async function runRemDreaming(params: {
timezone: params.config.timezone,
storage: params.config.storage,
});
await recordDreamingPhaseSignals({
workspaceDir: params.workspaceDir,
phase: "rem",
keys: preview.candidateKeys,
nowMs,
});
if (params.config.enabled && entries.length > 0 && params.config.storage.mode !== "separate") {
params.logger.info(
`memory-core: REM dreaming wrote reflections from ${entries.length} recent memory trace(s) [workspace=${params.workspaceDir}].`,
);
}
// Generate dream diary narrative from REM reflections.
if (params.subagent && entries.length > 0) {
const snippets = preview.candidateTruths.map((t) => t.snippet).filter(Boolean);
const themes = preview.reflections.filter(
(r) => !r.startsWith("- No strong") && !r.startsWith(" -"),
);
const data: NarrativePhaseData = {
phase: "rem",
snippets:
snippets.length > 0
? snippets
: entries
.slice(0, 8)
.map((e) => e.snippet)
.filter(Boolean),
...(themes.length > 0 ? { themes } : {}),
};
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir: params.workspaceDir,
data,
nowMs,
timezone: params.config.timezone,
logger: params.logger,
});
}
}
export async function runDreamingSweepPhases(params: {
workspaceDir: string;
pluginConfig?: Record<string, unknown>;
cfg?: OpenClawConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
nowMs?: number;
}): Promise<void> {
const light = resolveMemoryLightDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg,
});
if (light.enabled && light.limit > 0) {
await runLightDreaming({
workspaceDir: params.workspaceDir,
config: light,
logger: params.logger,
subagent: params.subagent,
nowMs: params.nowMs,
});
}
const rem = resolveMemoryRemDreamingConfig({
pluginConfig: params.pluginConfig,
cfg: params.cfg,
});
if (rem.enabled && rem.limit > 0) {
await runRemDreaming({
workspaceDir: params.workspaceDir,
config: rem,
logger: params.logger,
subagent: params.subagent,
nowMs: params.nowMs,
});
}
}
async function runPhaseIfTriggered(params: {
@@ -582,6 +991,7 @@ async function runPhaseIfTriggered(params: {
workspaceDir?: string;
cfg?: OpenClawConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
phase: "light" | "rem";
eventText: string;
config:
@@ -624,6 +1034,7 @@ async function runPhaseIfTriggered(params: {
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
},
logger: params.logger,
subagent: params.subagent,
});
} else {
await runRemDreaming({
@@ -633,6 +1044,7 @@ async function runPhaseIfTriggered(params: {
storage: { mode: "inline" | "separate" | "both"; separateReports: boolean };
},
logger: params.logger,
subagent: params.subagent,
});
}
} catch (err) {
@@ -713,6 +1125,7 @@ export function registerMemoryDreamingPhases(api: OpenClawPluginApi): void {
workspaceDir: ctx.workspaceDir,
cfg: api.config,
logger: api.logger,
subagent: light.enabled ? api.runtime?.subagent : undefined,
phase: "light",
eventText: LIGHT_SLEEP_EVENT_TEXT,
config: light,
@@ -727,6 +1140,7 @@ export function registerMemoryDreamingPhases(api: OpenClawPluginApi): void {
workspaceDir: ctx.workspaceDir,
cfg: api.config,
logger: api.logger,
subagent: rem.enabled ? api.runtime?.subagent : undefined,
phase: "rem",
eventText: REM_SLEEP_EVENT_TEXT,
config: rem,

View File

@@ -141,12 +141,12 @@ describe("short-term dreaming config", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
enabled: true,
timezone: "UTC",
verboseLogging: true,
frequency: "5 1 * * *",
phases: {
deep: {
cron: "15 2 * * *",
limit: 7,
minScore: 0.4,
minRecallCount: 2,
@@ -160,7 +160,7 @@ describe("short-term dreaming config", () => {
});
expect(resolved).toEqual({
enabled: true,
cron: "15 2 * * *",
cron: "5 1 * * *",
timezone: "UTC",
limit: 7,
minScore: 0.4,
@@ -176,14 +176,14 @@ describe("short-term dreaming config", () => {
});
});
it("accepts cron alias and numeric string thresholds", () => {
it("accepts top-level frequency and numeric string thresholds", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
enabled: true,
frequency: "5 1 * * *",
phases: {
deep: {
cron: "5 1 * * *",
limit: "4",
minScore: "0.6",
minRecallCount: "2",
@@ -216,7 +216,7 @@ describe("short-term dreaming config", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
enabled: true,
phases: {
deep: {
limit: " ",
@@ -251,7 +251,7 @@ describe("short-term dreaming config", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
enabled: true,
phases: {
deep: {
limit: 0,
@@ -267,7 +267,6 @@ describe("short-term dreaming config", () => {
const enabled = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
verboseLogging: true,
},
},
@@ -275,7 +274,6 @@ describe("short-term dreaming config", () => {
const disabled = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
verboseLogging: "false",
},
},
@@ -289,7 +287,7 @@ describe("short-term dreaming config", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "core",
enabled: true,
phases: {
deep: {
minScore: -0.2,
@@ -312,11 +310,15 @@ describe("short-term dreaming config", () => {
expect(resolved.maxAgeDays).toBe(30);
});
it("keeps deep sleep disabled when mode is off", () => {
it("keeps deep sleep disabled when the phase is off", () => {
const resolved = resolveShortTermPromotionDreamingConfig({
pluginConfig: {
dreaming: {
mode: "off",
phases: {
deep: {
enabled: false,
},
},
},
},
});
@@ -490,6 +492,63 @@ describe("short-term dreaming cron reconciliation", () => {
expect(harness.jobs.map((entry) => entry.id)).toEqual(["job-other"]);
});
it("prunes legacy light/rem dreaming cron jobs during reconciliation", async () => {
const deepManagedJob: CronJobLike = {
id: "job-deep",
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 legacyLightJob: CronJobLike = {
id: "job-light",
name: "Memory Light Dreaming",
description: "[managed-by=memory-core.dreaming.light] legacy",
enabled: true,
schedule: { kind: "cron", expr: "0 */6 * * *" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "__openclaw_memory_core_light_sleep__" },
createdAtMs: 8,
};
const legacyRemJob: CronJobLike = {
id: "job-rem",
name: "Memory REM Dreaming",
description: "[managed-by=memory-core.dreaming.rem] legacy",
enabled: true,
schedule: { kind: "cron", expr: "0 5 * * 0" },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "__openclaw_memory_core_rem_sleep__" },
createdAtMs: 9,
};
const harness = createCronHarness([legacyLightJob, legacyRemJob, deepManagedJob]);
const logger = createLogger();
const result = await reconcileShortTermDreamingCronJob({
cron: harness.cron,
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,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
},
logger,
});
expect(result.status).toBe("noop");
expect(result.removed).toBe(2);
expect(harness.removeCalls).toEqual(["job-light", "job-rem"]);
});
it("does not overcount removed jobs when cron remove result is unknown", async () => {
const managedJob: CronJobLike = {
id: "job-managed",

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk/memory-core";
import {
DEFAULT_MEMORY_DEEP_DREAMING_CRON_EXPR as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_MEMORY_DREAMING_FREQUENCY as DEFAULT_MEMORY_DREAMING_CRON_EXPR,
DEFAULT_MEMORY_DEEP_DREAMING_LIMIT as DEFAULT_MEMORY_DREAMING_LIMIT,
DEFAULT_MEMORY_DEEP_DREAMING_MIN_RECALL_COUNT as DEFAULT_MEMORY_DREAMING_MIN_RECALL_COUNT,
DEFAULT_MEMORY_DEEP_DREAMING_MIN_SCORE as DEFAULT_MEMORY_DREAMING_MIN_SCORE,
@@ -11,6 +11,8 @@ import {
resolveMemoryDreamingWorkspaces,
} from "openclaw/plugin-sdk/memory-core-host-status";
import { writeDeepDreamingReport } from "./dreaming-markdown.js";
import { generateAndAppendDreamNarrative, type NarrativePhaseData } from "./dreaming-narrative.js";
import { runDreamingSweepPhases } from "./dreaming-phases.js";
import {
applyShortTermPromotions,
repairShortTermPromotionArtifacts,
@@ -20,6 +22,12 @@ import {
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 LEGACY_LIGHT_SLEEP_CRON_NAME = "Memory Light Dreaming";
const LEGACY_LIGHT_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.light]";
const LEGACY_LIGHT_SLEEP_EVENT_TEXT = "__openclaw_memory_core_light_sleep__";
const LEGACY_REM_SLEEP_CRON_NAME = "Memory REM Dreaming";
const LEGACY_REM_SLEEP_CRON_TAG = "[managed-by=memory-core.dreaming.rem]";
const LEGACY_REM_SLEEP_EVENT_TEXT = "__openclaw_memory_core_rem_sleep__";
type Logger = Pick<OpenClawPluginApi["logger"], "info" | "warn" | "error">;
@@ -171,6 +179,22 @@ function isManagedDreamingJob(job: ManagedCronJobLike): boolean {
return name === MANAGED_DREAMING_CRON_NAME && payloadText === DREAMING_SYSTEM_EVENT_TEXT;
}
function isLegacyPhaseDreamingJob(job: ManagedCronJobLike): boolean {
const description = normalizeTrimmedString(job.description);
if (
description?.includes(LEGACY_LIGHT_SLEEP_CRON_TAG) ||
description?.includes(LEGACY_REM_SLEEP_CRON_TAG)
) {
return true;
}
const name = normalizeTrimmedString(job.name);
const payloadText = normalizeTrimmedString(job.payload?.text);
if (name === LEGACY_LIGHT_SLEEP_CRON_NAME && payloadText === LEGACY_LIGHT_SLEEP_EVENT_TEXT) {
return true;
}
return name === LEGACY_REM_SLEEP_CRON_NAME && payloadText === LEGACY_REM_SLEEP_EVENT_TEXT;
}
function compareOptionalStrings(a: string | undefined, b: string | undefined): boolean {
return a === b;
}
@@ -295,9 +319,27 @@ export async function reconcileShortTermDreamingCronJob(params: {
const allJobs = await cron.list({ includeDisabled: true });
const managed = allJobs.filter(isManagedDreamingJob);
const legacyPhaseJobs = allJobs.filter(isLegacyPhaseDreamingJob);
let removedLegacy = 0;
for (const job of legacyPhaseJobs) {
try {
const result = await cron.remove(job.id);
if (result.removed === true) {
removedLegacy += 1;
}
} catch (err) {
params.logger.warn(
`memory-core: failed to remove legacy managed dreaming cron job ${job.id}: ${formatErrorMessage(err)}`,
);
}
}
if (removedLegacy > 0) {
params.logger.info(`memory-core: removed ${removedLegacy} legacy phase dreaming cron job(s).`);
}
if (!params.config.enabled) {
let removed = 0;
let removed = removedLegacy;
for (const job of managed) {
try {
const result = await cron.remove(job.id);
@@ -320,11 +362,11 @@ export async function reconcileShortTermDreamingCronJob(params: {
if (managed.length === 0) {
await cron.add(desired);
params.logger.info("memory-core: created managed dreaming cron job.");
return { status: "added", removed: 0 };
return { status: "added", removed: removedLegacy };
}
const [primary, ...duplicates] = sortManagedJobs(managed);
let removed = 0;
let removed = removedLegacy;
for (const duplicate of duplicates) {
try {
const result = await cron.remove(duplicate.id);
@@ -358,6 +400,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
cfg?: OpenClawConfig;
config: ShortTermPromotionDreamingConfig;
logger: Logger;
subagent?: Parameters<typeof generateAndAppendDreamNarrative>[0]["subagent"];
}): Promise<{ handled: true; reason: string } | undefined> {
if (params.trigger !== "heartbeat") {
return undefined;
@@ -406,8 +449,19 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
let totalCandidates = 0;
let totalApplied = 0;
let failedWorkspaces = 0;
const pluginConfig = params.cfg ? resolveMemoryCorePluginConfig(params.cfg) : undefined;
for (const workspaceDir of workspaces) {
try {
const sweepNowMs = Date.now();
await runDreamingSweepPhases({
workspaceDir,
pluginConfig,
cfg: params.cfg,
logger: params.logger,
subagent: params.subagent,
nowMs: sweepNowMs,
});
const reportLines: string[] = [];
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
if (repair.changed) {
@@ -424,6 +478,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
minUniqueQueries: params.config.minUniqueQueries,
recencyHalfLifeDays,
maxAgeDays: params.config.maxAgeDays,
nowMs: sweepNowMs,
});
totalCandidates += candidates.length;
reportLines.push(`- Ranked ${candidates.length} candidate(s) for durable promotion.`);
@@ -450,6 +505,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
minUniqueQueries: params.config.minUniqueQueries,
maxAgeDays: params.config.maxAgeDays,
timezone: params.config.timezone,
nowMs: sweepNowMs,
});
totalApplied += applied.applied;
reportLines.push(`- Promoted ${applied.applied} candidate(s) into MEMORY.md.`);
@@ -470,9 +526,26 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
await writeDeepDreamingReport({
workspaceDir,
bodyLines: reportLines,
nowMs: sweepNowMs,
timezone: params.config.timezone,
storage: params.config.storage ?? { mode: "inline", separateReports: false },
});
// Generate dream diary narrative from promoted memories.
if (params.subagent && (candidates.length > 0 || applied.applied > 0)) {
const data: NarrativePhaseData = {
phase: "deep",
snippets: candidates.map((c) => c.snippet).filter(Boolean),
promotions: applied.appliedCandidates.map((c) => c.snippet).filter(Boolean),
};
await generateAndAppendDreamNarrative({
subagent: params.subagent,
workspaceDir,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
logger: params.logger,
});
}
} catch (err) {
failedWorkspaces += 1;
params.logger.error(
@@ -529,6 +602,7 @@ export function registerShortTermPromotionDreaming(api: OpenClawPluginApi): void
cfg: api.config,
config,
logger: api.logger,
subagent: config.enabled ? api.runtime?.subagent : undefined,
});
} catch (err) {
api.logger.error(`memory-core: dreaming trigger failed: ${formatErrorMessage(err)}`);

View File

@@ -7,9 +7,11 @@ import {
auditShortTermPromotionArtifacts,
isShortTermMemoryPath,
rankShortTermPromotionCandidates,
recordDreamingPhaseSignals,
recordShortTermRecalls,
repairShortTermPromotionArtifacts,
resolveShortTermRecallLockPath,
resolveShortTermPhaseSignalStorePath,
resolveShortTermRecallStorePath,
__testing,
} from "./short-term-promotion.js";
@@ -249,6 +251,178 @@ describe("short-term promotion", () => {
});
});
it("boosts deep ranking when light/rem phase signals reinforce a candidate", async () => {
await withTempWorkspace(async (workspaceDir) => {
const nowMs = Date.parse("2026-04-05T10:00:00.000Z");
await recordShortTermRecalls({
workspaceDir,
query: "router setup",
nowMs,
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.75,
snippet: "Router VLAN baseline noted.",
source: "memory",
},
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.75,
snippet: "Backup policy for router snapshots.",
source: "memory",
},
],
});
await recordShortTermRecalls({
workspaceDir,
query: "router backup",
nowMs,
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.75,
snippet: "Router VLAN baseline noted.",
source: "memory",
},
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.75,
snippet: "Backup policy for router snapshots.",
source: "memory",
},
],
});
const baseline = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs,
});
expect(baseline).toHaveLength(2);
expect(baseline[0]?.path).toBe("memory/2026-04-01.md");
const boostedKey = baseline.find((entry) => entry.path === "memory/2026-04-02.md")?.key;
expect(boostedKey).toBeTruthy();
await recordDreamingPhaseSignals({
workspaceDir,
phase: "light",
keys: [boostedKey!],
nowMs,
});
await recordDreamingPhaseSignals({
workspaceDir,
phase: "rem",
keys: [boostedKey!],
nowMs,
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs,
});
expect(ranked[0]?.path).toBe("memory/2026-04-02.md");
expect(ranked[0]!.score).toBeGreaterThan(ranked[1]!.score);
const phaseStorePath = resolveShortTermPhaseSignalStorePath(workspaceDir);
const phaseStore = JSON.parse(await fs.readFile(phaseStorePath, "utf-8")) as {
entries: Record<string, { lightHits: number; remHits: number }>;
};
expect(phaseStore.entries[boostedKey!]).toMatchObject({
lightHits: 1,
remHits: 1,
});
});
});
it("weights fresh phase signals more than stale ones", async () => {
await withTempWorkspace(async (workspaceDir) => {
await recordShortTermRecalls({
workspaceDir,
query: "glacier cadence",
nowMs: Date.parse("2026-04-01T10:00:00.000Z"),
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
await recordShortTermRecalls({
workspaceDir,
query: "backup lifecycle",
nowMs: Date.parse("2026-04-01T12:00:00.000Z"),
results: [
{
path: "memory/2026-04-01.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const rankedBaseline = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
});
const key = rankedBaseline[0]?.key;
expect(key).toBeTruthy();
await recordDreamingPhaseSignals({
workspaceDir,
phase: "rem",
keys: [key!],
nowMs: Date.parse("2026-02-01T10:00:00.000Z"),
});
const staleSignalRank = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
});
await recordDreamingPhaseSignals({
workspaceDir,
phase: "rem",
keys: [key!],
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
});
const freshSignalRank = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
nowMs: Date.parse("2026-04-05T10:00:00.000Z"),
});
expect(staleSignalRank).toHaveLength(1);
expect(freshSignalRank).toHaveLength(1);
expect(freshSignalRank[0]!.score).toBeGreaterThan(staleSignalRank[0]!.score);
});
});
it("reconciles existing promotion markers instead of appending duplicates", async () => {
await withTempWorkspace(async (workspaceDir) => {
await writeDailyMemoryNote(workspaceDir, "2026-04-01", [

View File

@@ -21,10 +21,14 @@ const PROMOTION_MARKER_PREFIX = "openclaw-memory-promotion:";
const MAX_QUERY_HASHES = 32;
const MAX_RECALL_DAYS = 16;
const SHORT_TERM_STORE_RELATIVE_PATH = path.join("memory", ".dreams", "short-term-recall.json");
const SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH = path.join("memory", ".dreams", "phase-signals.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;
const PHASE_SIGNAL_LIGHT_BOOST_MAX = 0.05;
const PHASE_SIGNAL_REM_BOOST_MAX = 0.08;
const PHASE_SIGNAL_HALF_LIFE_DAYS = 14;
export type PromotionWeights = {
frequency: number;
@@ -52,6 +56,7 @@ export type ShortTermRecallEntry = {
source: "memory";
snippet: string;
recallCount: number;
dailyCount: number;
totalScore: number;
maxScore: number;
firstRecalledAt: string;
@@ -68,6 +73,20 @@ type ShortTermRecallStore = {
entries: Record<string, ShortTermRecallEntry>;
};
type ShortTermPhaseSignalEntry = {
key: string;
lightHits: number;
remHits: number;
lastLightAt?: string;
lastRemAt?: string;
};
type ShortTermPhaseSignalStore = {
version: 1;
updatedAt: string;
entries: Record<string, ShortTermPhaseSignalEntry>;
};
export type PromotionComponents = {
frequency: number;
relevance: number;
@@ -85,6 +104,8 @@ export type PromotionCandidate = {
source: "memory";
snippet: string;
recallCount: number;
dailyCount?: number;
signalCount?: number;
avgScore: number;
maxScore: number;
uniqueQueries: number;
@@ -339,6 +360,7 @@ function normalizeStore(raw: unknown, nowIso: string): ShortTermRecallStore {
}
const recallCount = Math.max(0, Math.floor(Number(entry.recallCount) || 0));
const dailyCount = Math.max(0, Math.floor(Number(entry.dailyCount) || 0));
const totalScore = Math.max(0, Number(entry.totalScore) || 0);
const maxScore = clampScore(Number(entry.maxScore) || 0);
const firstRecalledAt =
@@ -371,6 +393,7 @@ function normalizeStore(raw: unknown, nowIso: string): ShortTermRecallStore {
source,
snippet,
recallCount,
dailyCount,
totalScore,
maxScore,
firstRecalledAt,
@@ -446,10 +469,50 @@ function calculateRecencyComponent(ageDays: number, halfLifeDays: number): numbe
return Math.exp(-lambda * ageDays);
}
function calculatePhaseSignalAgeDays(lastSeenAt: string | undefined, nowMs: number): number | null {
if (!lastSeenAt) {
return null;
}
const parsed = Date.parse(lastSeenAt);
if (!Number.isFinite(parsed)) {
return null;
}
return Math.max(0, (nowMs - parsed) / DAY_MS);
}
function calculatePhaseSignalBoost(
entry: ShortTermPhaseSignalEntry | undefined,
nowMs: number,
): number {
if (!entry) {
return 0;
}
const lightStrength = clampScore(Math.log1p(Math.max(0, entry.lightHits)) / Math.log1p(6));
const remStrength = clampScore(Math.log1p(Math.max(0, entry.remHits)) / Math.log1p(6));
const lightAgeDays = calculatePhaseSignalAgeDays(entry.lastLightAt, nowMs);
const remAgeDays = calculatePhaseSignalAgeDays(entry.lastRemAt, nowMs);
const lightRecency =
lightAgeDays === null
? 0
: clampScore(calculateRecencyComponent(lightAgeDays, PHASE_SIGNAL_HALF_LIFE_DAYS));
const remRecency =
remAgeDays === null
? 0
: clampScore(calculateRecencyComponent(remAgeDays, PHASE_SIGNAL_HALF_LIFE_DAYS));
return clampScore(
PHASE_SIGNAL_LIGHT_BOOST_MAX * lightStrength * lightRecency +
PHASE_SIGNAL_REM_BOOST_MAX * remStrength * remRecency,
);
}
function resolveStorePath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_STORE_RELATIVE_PATH);
}
function resolvePhaseSignalPath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_PHASE_SIGNAL_RELATIVE_PATH);
}
function resolveLockPath(workspaceDir: string): string {
return path.join(workspaceDir, SHORT_TERM_LOCK_RELATIVE_PATH);
}
@@ -552,6 +615,89 @@ async function readStore(workspaceDir: string, nowIso: string): Promise<ShortTer
}
}
function emptyPhaseSignalStore(nowIso: string): ShortTermPhaseSignalStore {
return {
version: 1,
updatedAt: nowIso,
entries: {},
};
}
function normalizePhaseSignalStore(raw: unknown, nowIso: string): ShortTermPhaseSignalStore {
const record = asRecord(raw);
if (!record) {
return emptyPhaseSignalStore(nowIso);
}
const entriesRaw = asRecord(record?.entries);
if (!entriesRaw) {
return emptyPhaseSignalStore(nowIso);
}
const entries: Record<string, ShortTermPhaseSignalEntry> = {};
for (const [mapKey, value] of Object.entries(entriesRaw)) {
const entry = asRecord(value);
if (!entry) {
continue;
}
const key = typeof entry.key === "string" && entry.key.trim().length > 0 ? entry.key : mapKey;
const lightHits = toFiniteNonNegativeInt(entry.lightHits, 0);
const remHits = toFiniteNonNegativeInt(entry.remHits, 0);
if (lightHits === 0 && remHits === 0) {
continue;
}
const lastLightAt =
typeof entry.lastLightAt === "string" && entry.lastLightAt.trim().length > 0
? entry.lastLightAt
: undefined;
const lastRemAt =
typeof entry.lastRemAt === "string" && entry.lastRemAt.trim().length > 0
? entry.lastRemAt
: undefined;
entries[key] = {
key,
lightHits,
remHits,
...(lastLightAt ? { lastLightAt } : {}),
...(lastRemAt ? { lastRemAt } : {}),
};
}
return {
version: 1,
updatedAt:
typeof record.updatedAt === "string" && record.updatedAt.trim().length > 0
? record.updatedAt
: nowIso,
entries,
};
}
async function readPhaseSignalStore(
workspaceDir: string,
nowIso: string,
): Promise<ShortTermPhaseSignalStore> {
const phaseSignalPath = resolvePhaseSignalPath(workspaceDir);
try {
const raw = await fs.readFile(phaseSignalPath, "utf-8");
return normalizePhaseSignalStore(JSON.parse(raw) as unknown, nowIso);
} catch (err) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code === "ENOENT" || err instanceof SyntaxError) {
return emptyPhaseSignalStore(nowIso);
}
return emptyPhaseSignalStore(nowIso);
}
}
async function writePhaseSignalStore(
workspaceDir: string,
store: ShortTermPhaseSignalStore,
): Promise<void> {
const phaseSignalPath = resolvePhaseSignalPath(workspaceDir);
await fs.mkdir(path.dirname(phaseSignalPath), { recursive: true });
const tmpPath = `${phaseSignalPath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
await fs.writeFile(tmpPath, `${JSON.stringify(store, null, 2)}\n`, "utf-8");
await fs.rename(tmpPath, phaseSignalPath);
}
async function writeStore(workspaceDir: string, store: ShortTermRecallStore): Promise<void> {
const storePath = resolveStorePath(workspaceDir);
await fs.mkdir(path.dirname(storePath), { recursive: true });
@@ -572,6 +718,9 @@ export async function recordShortTermRecalls(params: {
workspaceDir?: string;
query: string;
results: MemorySearchResult[];
signalType?: "recall" | "daily";
dedupeByQueryPerDay?: boolean;
dayBucket?: string;
nowMs?: number;
timezone?: string;
}): Promise<void> {
@@ -592,7 +741,10 @@ export async function recordShortTermRecalls(params: {
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
const signalType = params.signalType ?? "recall";
const queryHash = hashQuery(query);
const todayBucket =
normalizeIsoDay(params.dayBucket ?? "") ?? formatMemoryDreamingDay(nowMs, params.timezone);
await withShortTermLock(workspaceDir, async () => {
const store = await readStore(workspaceDir, nowIso);
@@ -602,15 +754,24 @@ export async function recordShortTermRecalls(params: {
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 recallDaysBase = existing?.recallDays ?? [];
const queryHashesBase = existing?.queryHashes ?? [];
const dedupeSignal =
Boolean(params.dedupeByQueryPerDay) &&
queryHashesBase.includes(queryHash) &&
recallDaysBase.includes(todayBucket);
const recallCount =
signalType === "recall"
? Math.max(0, Math.floor(existing?.recallCount ?? 0) + (dedupeSignal ? 0 : 1))
: Math.max(0, Math.floor(existing?.recallCount ?? 0));
const dailyCount =
signalType === "daily"
? Math.max(0, Math.floor(existing?.dailyCount ?? 0) + (dedupeSignal ? 0 : 1))
: Math.max(0, Math.floor(existing?.dailyCount ?? 0));
const totalScore = Math.max(0, (existing?.totalScore ?? 0) + (dedupeSignal ? 0 : score));
const maxScore = Math.max(existing?.maxScore ?? 0, dedupeSignal ? 0 : score);
const queryHashes = mergeQueryHashes(existing?.queryHashes ?? [], queryHash);
const recallDays = mergeRecentDistinct(
existing?.recallDays ?? [],
formatMemoryDreamingDay(nowMs, params.timezone),
MAX_RECALL_DAYS,
);
const recallDays = mergeRecentDistinct(recallDaysBase, todayBucket, MAX_RECALL_DAYS);
const conceptTags = deriveConceptTags({ path: normalizedPath, snippet });
store.entries[key] = {
@@ -621,6 +782,7 @@ export async function recordShortTermRecalls(params: {
source: "memory",
snippet: snippet || existing?.snippet || "",
recallCount,
dailyCount,
totalScore,
maxScore,
firstRecalledAt: existing?.firstRecalledAt ?? nowIso,
@@ -637,6 +799,60 @@ export async function recordShortTermRecalls(params: {
});
}
export async function recordDreamingPhaseSignals(params: {
workspaceDir?: string;
phase: "light" | "rem";
keys: string[];
nowMs?: number;
}): Promise<void> {
const workspaceDir = params.workspaceDir?.trim();
if (!workspaceDir) {
return;
}
const keys = [...new Set(params.keys.map((key) => key.trim()).filter(Boolean))];
if (keys.length === 0) {
return;
}
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
const nowIso = new Date(nowMs).toISOString();
await withShortTermLock(workspaceDir, async () => {
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
]);
const knownKeys = new Set(Object.keys(store.entries));
for (const key of keys) {
if (!knownKeys.has(key)) {
continue;
}
const entry = phaseSignals.entries[key] ?? {
key,
lightHits: 0,
remHits: 0,
};
if (params.phase === "light") {
entry.lightHits = Math.min(9999, entry.lightHits + 1);
entry.lastLightAt = nowIso;
} else {
entry.remHits = Math.min(9999, entry.remHits + 1);
entry.lastRemAt = nowIso;
}
phaseSignals.entries[key] = entry;
}
for (const [key, entry] of Object.entries(phaseSignals.entries)) {
if (!knownKeys.has(key) || (entry.lightHits <= 0 && entry.remHits <= 0)) {
delete phaseSignals.entries[key];
}
}
phaseSignals.updatedAt = nowIso;
await writePhaseSignalStore(workspaceDir, phaseSignals);
});
}
export async function rankShortTermPromotionCandidates(
options: RankShortTermPromotionOptions,
): Promise<PromotionCandidate[]> {
@@ -664,7 +880,10 @@ export async function rankShortTermPromotionCandidates(
);
const weights = normalizeWeights(options.weights);
const store = await readStore(workspaceDir, nowIso);
const [store, phaseSignals] = await Promise.all([
readStore(workspaceDir, nowIso),
readPhaseSignalStore(workspaceDir, nowIso),
]);
const candidates: PromotionCandidate[] = [];
for (const entry of Object.values(store.entries)) {
@@ -674,20 +893,24 @@ export async function rankShortTermPromotionCandidates(
if (!includePromoted && entry.promotedAt) {
continue;
}
if (!Number.isFinite(entry.recallCount) || entry.recallCount <= 0) {
const recallCount = Math.max(0, Math.floor(entry.recallCount ?? 0));
const dailyCount = Math.max(0, Math.floor(entry.dailyCount ?? 0));
const signalCount = recallCount + dailyCount;
if (signalCount <= 0) {
continue;
}
if (entry.recallCount < minRecallCount) {
if (signalCount < minRecallCount) {
continue;
}
const avgScore = clampScore(entry.totalScore / Math.max(1, entry.recallCount));
const frequency = clampScore(Math.log1p(entry.recallCount) / Math.log1p(10));
const avgScore = clampScore(entry.totalScore / Math.max(1, signalCount));
const frequency = clampScore(Math.log1p(signalCount) / Math.log1p(10));
const uniqueQueries = entry.queryHashes?.length ?? 0;
if (uniqueQueries < minUniqueQueries) {
const contextDiversity = Math.max(uniqueQueries, entry.recallDays?.length ?? 0);
if (contextDiversity < minUniqueQueries) {
continue;
}
const diversity = clampScore(uniqueQueries / 5);
const diversity = clampScore(contextDiversity / 5);
const lastRecalledAtMs = Date.parse(entry.lastRecalledAt);
const ageDays = Number.isFinite(lastRecalledAtMs)
? Math.max(0, (nowMs - lastRecalledAtMs) / DAY_MS)
@@ -701,13 +924,15 @@ export async function rankShortTermPromotionCandidates(
const consolidation = calculateConsolidationComponent(recallDays);
const conceptual = calculateConceptualComponent(conceptTags);
const phaseBoost = calculatePhaseSignalBoost(phaseSignals.entries[entry.key], nowMs);
const score =
weights.frequency * frequency +
weights.relevance * avgScore +
weights.diversity * diversity +
weights.recency * recency +
weights.consolidation * consolidation +
weights.conceptual * conceptual;
weights.conceptual * conceptual +
phaseBoost;
if (score < minScore) {
continue;
@@ -720,7 +945,9 @@ export async function rankShortTermPromotionCandidates(
endLine: entry.endLine,
source: entry.source,
snippet: entry.snippet,
recallCount: entry.recallCount,
recallCount,
dailyCount,
signalCount,
avgScore,
maxScore: clampScore(entry.maxScore),
uniqueQueries,
@@ -998,10 +1225,13 @@ export async function applyShortTermPromotions(
if (candidate.score < minScore) {
return false;
}
if (candidate.recallCount < minRecallCount) {
const candidateSignalCount =
candidate.signalCount ??
Math.max(0, candidate.recallCount) + Math.max(0, candidate.dailyCount ?? 0);
if (candidateSignalCount < minRecallCount) {
return false;
}
if (candidate.uniqueQueries < minUniqueQueries) {
if (Math.max(candidate.uniqueQueries, candidate.recallDays.length) < minUniqueQueries) {
return false;
}
if (maxAgeDays >= 0 && candidate.ageDays > maxAgeDays) {
@@ -1082,6 +1312,10 @@ export function resolveShortTermRecallStorePath(workspaceDir: string): string {
return resolveStorePath(workspaceDir);
}
export function resolveShortTermPhaseSignalStorePath(workspaceDir: string): string {
return resolvePhaseSignalPath(workspaceDir);
}
export function resolveShortTermRecallLockPath(workspaceDir: string): string {
return resolveLockPath(workspaceDir);
}
@@ -1286,6 +1520,10 @@ export async function repairShortTermPromotionArtifacts(params: {
key,
{
...entry,
dailyCount: Math.max(
0,
Math.floor((entry as { dailyCount?: number }).dailyCount ?? 0),
),
queryHashes: (entry.queryHashes ?? []).slice(-MAX_QUERY_HASHES),
recallDays: mergeRecentDistinct(entry.recallDays ?? [], fallbackDay, MAX_RECALL_DAYS),
conceptTags: conceptTags.length > 0 ? conceptTags : (entry.conceptTags ?? []),
@@ -1327,4 +1565,5 @@ export const __testing = {
isProcessLikelyAlive,
deriveConceptTags,
calculateConsolidationComponent,
calculatePhaseSignalBoost,
};