fix(FN-000): rebuild layered memory system

This commit is contained in:
gsxdsm
2026-04-17 07:36:22 -07:00
parent ad987bf174
commit bdfb4842d2
24 changed files with 1835 additions and 116 deletions

View File

@@ -422,6 +422,8 @@ export {
buildExecutionMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
searchProjectMemory,
getProjectMemory,
resolveMemoryInstructionContext,
type MemoryInstructionContext,
} from "./project-memory.js";
@@ -432,6 +434,21 @@ export {
FileMemoryBackend,
ReadOnlyMemoryBackend,
QmdMemoryBackend,
MEMORY_WORKSPACE_PATH,
MEMORY_LONG_TERM_FILENAME,
MEMORY_DREAMS_FILENAME,
LEGACY_MEMORY_FILE_PATH,
memoryWorkspacePath,
memoryLongTermPath,
memoryDreamsPath,
dailyMemoryPath,
getDefaultLongTermMemoryScaffold,
getDefaultDailyMemoryScaffold,
getDefaultDreamsScaffold,
ensureOpenClawMemoryFiles,
listProjectMemoryFiles,
readProjectMemoryFile,
writeProjectMemoryFile,
} from "./memory-backend.js";
export {
@@ -449,7 +466,18 @@ export {
export { MemoryBackendError } from "./memory-backend.js";
export type { MemoryBackendCapabilities } from "./memory-backend.js";
export type { MemoryBackendCapabilities, MemoryFileInfo, MemoryGetOptions, MemoryGetResult, MemorySearchOptions, MemorySearchResult } from "./memory-backend.js";
export {
buildDreamProcessingPrompt,
createMemoryDreamsAutomation,
DEFAULT_MEMORY_DREAMS_SCHEDULE,
extractDreamProcessorResult,
MEMORY_DREAMS_SCHEDULE_NAME,
processMemoryDreams,
syncMemoryDreamsAutomation,
} from "./memory-dreams.js";
export type { DreamProcessorResult, DreamPromptExecutor } from "./memory-dreams.js";
// ── Project Insights ──────────────────────────────────────────────────────

View File

@@ -9,9 +9,19 @@
* `.fusion/memory.md`.
*/
import { readFile, writeFile, mkdir, access, constants } from "node:fs/promises";
import { readFile, writeFile, mkdir, access, constants, readdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { basename, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
export const MEMORY_WORKSPACE_PATH = ".fusion/memory";
export const MEMORY_LONG_TERM_FILENAME = "MEMORY.md";
export const MEMORY_DREAMS_FILENAME = "DREAMS.md";
export const LEGACY_MEMORY_FILE_PATH = ".fusion/memory.md";
const DAILY_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
const MAX_MEMORY_SNIPPET_CHARS = 700;
const DEFAULT_MEMORY_GET_LINES = 120;
const MAX_MEMORY_GET_LINES = 400;
// ── Type Definitions ────────────────────────────────────────────────
@@ -54,6 +64,43 @@ export interface MemoryWriteResult {
backend: string;
}
export interface MemoryGetOptions {
path: string;
startLine?: number;
lineCount?: number;
}
export interface MemoryGetResult {
path: string;
content: string;
startLine: number;
endLine: number;
totalLines: number;
backend: string;
}
export interface MemorySearchOptions {
query: string;
limit?: number;
}
export interface MemorySearchResult {
path: string;
lineStart: number;
lineEnd: number;
snippet: string;
score: number;
backend: string;
}
export interface MemoryFileInfo {
path: string;
label: string;
layer: "long-term" | "daily" | "dreams" | "legacy";
size: number;
updatedAt: string;
}
/**
* Error codes for memory operations.
*/
@@ -108,6 +155,16 @@ export interface MemoryBackend {
* @throws MemoryBackendError if writing fails or backend is read-only
*/
write(rootDir: string, content: string): Promise<MemoryWriteResult>;
/**
* Read a specific memory file or line window. Implementations must reject
* paths outside the memory workspace.
*/
get?(rootDir: string, options: MemoryGetOptions): Promise<MemoryGetResult>;
/**
* Search memory files. Backends may use keyword search, vector search, or
* external sidecars, but should return bounded snippets rather than full files.
*/
search?(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]>;
/**
* Check if memory exists for a project.
* @param rootDir - The project root directory
@@ -153,11 +210,21 @@ export class FileMemoryBackend implements MemoryBackend {
* Get the absolute path to the memory file.
*/
private getFilePath(rootDir: string): string {
return join(rootDir, ".fusion", "memory.md");
return join(rootDir, LEGACY_MEMORY_FILE_PATH);
}
private getLongTermPath(rootDir: string): string {
return join(rootDir, MEMORY_WORKSPACE_PATH, MEMORY_LONG_TERM_FILENAME);
}
async read(rootDir: string): Promise<MemoryReadResult> {
const filePath = this.getFilePath(rootDir);
const longTermPath = this.getLongTermPath(rootDir);
const legacyPath = this.getFilePath(rootDir);
let filePath = existsSync(longTermPath) ? longTermPath : legacyPath;
if (existsSync(longTermPath) && existsSync(legacyPath)) {
const [longTermStat, legacyStat] = await Promise.all([stat(longTermPath), stat(legacyPath)]);
filePath = legacyStat.mtimeMs > longTermStat.mtimeMs ? legacyPath : longTermPath;
}
try {
const content = await readFile(filePath, "utf-8");
return {
@@ -182,14 +249,19 @@ export class FileMemoryBackend implements MemoryBackend {
}
async write(rootDir: string, content: string): Promise<MemoryWriteResult> {
const filePath = this.getFilePath(rootDir);
const dir = join(rootDir, ".fusion");
const filePath = this.getLongTermPath(rootDir);
const dir = join(rootDir, MEMORY_WORKSPACE_PATH);
const legacyPath = this.getFilePath(rootDir);
const legacyDir = join(rootDir, ".fusion");
try {
// Ensure directory exists
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
if (!existsSync(legacyDir)) {
await mkdir(legacyDir, { recursive: true });
}
// Write atomically using temp file
const tmpPath = filePath + ".tmp";
@@ -199,6 +271,11 @@ export class FileMemoryBackend implements MemoryBackend {
const { rename } = await import("node:fs/promises");
await rename(tmpPath, filePath);
// Temporary compatibility mirror while callers migrate to the layered path.
const legacyTmpPath = legacyPath + ".tmp";
await writeFile(legacyTmpPath, content, "utf-8");
await rename(legacyTmpPath, legacyPath);
return {
success: true,
backend: this.type,
@@ -213,7 +290,9 @@ export class FileMemoryBackend implements MemoryBackend {
}
async exists(rootDir: string): Promise<boolean> {
const filePath = this.getFilePath(rootDir);
const filePath = existsSync(this.getLongTermPath(rootDir))
? this.getLongTermPath(rootDir)
: this.getFilePath(rootDir);
try {
await access(filePath, constants.R_OK);
return true;
@@ -221,6 +300,14 @@ export class FileMemoryBackend implements MemoryBackend {
return false;
}
}
async get(rootDir: string, options: MemoryGetOptions): Promise<MemoryGetResult> {
return getMemoryFile(rootDir, options, this.type);
}
async search(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]> {
return searchMemoryFiles(rootDir, options, this.type);
}
}
/**
@@ -255,6 +342,18 @@ export class ReadOnlyMemoryBackend implements MemoryBackend {
this.type,
);
}
async get(_rootDir: string, options: MemoryGetOptions): Promise<MemoryGetResult> {
throw new MemoryBackendError(
"NOT_FOUND",
`Memory path '${options.path}' not found`,
this.type,
);
}
async search(_rootDir: string, _options: MemorySearchOptions): Promise<MemorySearchResult[]> {
return [];
}
}
/**
@@ -333,6 +432,334 @@ export class QmdMemoryBackend implements MemoryBackend {
async exists(rootDir: string): Promise<boolean> {
return this.fileBackend.exists(rootDir);
}
async get(rootDir: string, options: MemoryGetOptions): Promise<MemoryGetResult> {
return getMemoryFile(rootDir, options, this.type);
}
async search(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]> {
const qmdResults = await searchWithQmd(rootDir, options);
if (qmdResults.length > 0) {
return qmdResults.map((result) => ({ ...result, backend: this.type }));
}
return searchMemoryFiles(rootDir, options, "file");
}
}
export function memoryWorkspacePath(rootDir: string): string {
return join(rootDir, MEMORY_WORKSPACE_PATH);
}
export function memoryLongTermPath(rootDir: string): string {
return join(memoryWorkspacePath(rootDir), MEMORY_LONG_TERM_FILENAME);
}
export function memoryDreamsPath(rootDir: string): string {
return join(memoryWorkspacePath(rootDir), MEMORY_DREAMS_FILENAME);
}
export function dailyMemoryPath(rootDir: string, date = new Date()): string {
return join(memoryWorkspacePath(rootDir), `${date.toISOString().slice(0, 10)}.md`);
}
export function getDefaultLongTermMemoryScaffold(): string {
return `# Project Memory
<!-- Curated long-term memory. Store durable decisions, conventions, preferences, and pitfalls. -->
## Decisions
## Conventions
## Pitfalls
## Context
`;
}
export function getDefaultDailyMemoryScaffold(date = new Date()): string {
return `# Daily Memory ${date.toISOString().slice(0, 10)}
<!-- Append running observations, open loops, and day-to-day notes here. Promote evergreen facts to MEMORY.md. -->
`;
}
export function getDefaultDreamsScaffold(): string {
return `# Memory Dreams
<!-- Periodic synthesized patterns from daily notes. Promote durable lessons to MEMORY.md. -->
`;
}
export async function ensureOpenClawMemoryFiles(rootDir: string, date = new Date()): Promise<{ longTermCreated: boolean; dailyCreated: boolean }> {
const workspacePath = memoryWorkspacePath(rootDir);
await mkdir(workspacePath, { recursive: true });
const longTermPath = memoryLongTermPath(rootDir);
let longTermCreated = false;
if (!existsSync(longTermPath)) {
const legacyPath = join(rootDir, LEGACY_MEMORY_FILE_PATH);
const content = existsSync(legacyPath)
? await readFile(legacyPath, "utf-8")
: getDefaultLongTermMemoryScaffold();
await writeFile(longTermPath, content, "utf-8");
longTermCreated = true;
}
const todayPath = dailyMemoryPath(rootDir, date);
let dailyCreated = false;
if (!existsSync(todayPath)) {
await writeFile(todayPath, getDefaultDailyMemoryScaffold(date), "utf-8");
dailyCreated = true;
}
const dreamsPath = memoryDreamsPath(rootDir);
if (!existsSync(dreamsPath)) {
await writeFile(dreamsPath, getDefaultDreamsScaffold(), "utf-8");
}
return { longTermCreated, dailyCreated };
}
function getMemoryFileLayer(displayPath: string): MemoryFileInfo["layer"] {
if (displayPath === `${MEMORY_WORKSPACE_PATH}/${MEMORY_LONG_TERM_FILENAME}`) return "long-term";
if (displayPath === `${MEMORY_WORKSPACE_PATH}/${MEMORY_DREAMS_FILENAME}`) return "dreams";
if (displayPath === LEGACY_MEMORY_FILE_PATH) return "legacy";
return "daily";
}
function getMemoryFileLabel(displayPath: string): string {
const layer = getMemoryFileLayer(displayPath);
if (layer === "long-term") return "Long-term memory";
if (layer === "dreams") return "Dreams";
if (layer === "legacy") return "Legacy memory";
return `Daily notes ${basename(displayPath, ".md")}`;
}
export async function listProjectMemoryFiles(rootDir: string, date = new Date()): Promise<MemoryFileInfo[]> {
await ensureOpenClawMemoryFiles(rootDir, date);
const files = await listMemoryFiles(rootDir);
const uniqueFiles = Array.from(new Map(files.map((file) => [file.displayPath, file])).values());
const infos = await Promise.all(uniqueFiles.map(async (file) => {
const fileStat = await stat(file.absPath);
return {
path: file.displayPath,
label: getMemoryFileLabel(file.displayPath),
layer: getMemoryFileLayer(file.displayPath),
size: fileStat.size,
updatedAt: fileStat.mtime.toISOString(),
} satisfies MemoryFileInfo;
}));
const order: Record<MemoryFileInfo["layer"], number> = {
"long-term": 0,
daily: 1,
dreams: 2,
legacy: 3,
};
return infos.sort((a, b) => order[a.layer] - order[b.layer] || b.path.localeCompare(a.path));
}
export async function readProjectMemoryFile(rootDir: string, options: MemoryGetOptions): Promise<MemoryGetResult> {
return getMemoryFile(rootDir, options, "file");
}
export async function writeProjectMemoryFile(rootDir: string, path: string, content: string): Promise<MemoryWriteResult> {
const { absPath, displayPath } = resolveMemoryFilePath(rootDir, path);
await mkdir(dirname(absPath), { recursive: true });
const tmpPath = `${absPath}.tmp`;
await writeFile(tmpPath, content, "utf-8");
const { rename } = await import("node:fs/promises");
await rename(tmpPath, absPath);
if (displayPath === `${MEMORY_WORKSPACE_PATH}/${MEMORY_LONG_TERM_FILENAME}`) {
const legacyPath = join(rootDir, LEGACY_MEMORY_FILE_PATH);
const legacyTmpPath = `${legacyPath}.tmp`;
await writeFile(legacyTmpPath, content, "utf-8");
await rename(legacyTmpPath, legacyPath);
}
return { success: true, backend: "file" };
}
function isPathTraversal(path: string): boolean {
return path.split(/[\\/]+/).includes("..");
}
function normalizeMemoryRequestPath(rawPath: string): string {
const trimmed = rawPath.trim();
if (!trimmed) {
throw new MemoryBackendError("NOT_FOUND", "Memory path is required", "memory");
}
if (isAbsolute(trimmed) || isPathTraversal(trimmed)) {
throw new MemoryBackendError("UNSUPPORTED", "Memory paths must be workspace-relative", "memory");
}
const normalized = normalize(trimmed).replace(/\\/g, "/");
if (
normalized === MEMORY_LONG_TERM_FILENAME
|| normalized === MEMORY_DREAMS_FILENAME
|| normalized === `memory/${MEMORY_LONG_TERM_FILENAME}`
|| normalized === `memory/${MEMORY_DREAMS_FILENAME}`
) {
return `${MEMORY_WORKSPACE_PATH}/${basename(normalized)}`;
}
if (normalized === LEGACY_MEMORY_FILE_PATH) {
return normalized;
}
if (DAILY_MEMORY_RE.test(basename(normalized)) && (normalized === basename(normalized) || normalized.startsWith("memory/"))) {
return `${MEMORY_WORKSPACE_PATH}/${basename(normalized)}`;
}
if (normalized.startsWith(`${MEMORY_WORKSPACE_PATH}/`)) {
const file = basename(normalized);
if (file === MEMORY_LONG_TERM_FILENAME || file === MEMORY_DREAMS_FILENAME || DAILY_MEMORY_RE.test(file)) {
return `${MEMORY_WORKSPACE_PATH}/${file}`;
}
}
throw new MemoryBackendError(
"UNSUPPORTED",
`Memory path '${rawPath}' is outside allowed files: MEMORY.md, DREAMS.md, memory/YYYY-MM-DD.md, .fusion/memory.md`,
"memory",
);
}
function resolveMemoryFilePath(rootDir: string, requestedPath: string): { absPath: string; displayPath: string } {
const displayPath = normalizeMemoryRequestPath(requestedPath);
const absPath = resolve(rootDir, displayPath);
const rel = relative(rootDir, absPath);
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
throw new MemoryBackendError("UNSUPPORTED", "Memory path escapes project root", "memory");
}
return { absPath, displayPath };
}
async function getMemoryFile(rootDir: string, options: MemoryGetOptions, backend: string): Promise<MemoryGetResult> {
const { absPath, displayPath } = resolveMemoryFilePath(rootDir, options.path);
let content: string;
try {
content = await readFile(absPath, "utf-8");
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new MemoryBackendError("NOT_FOUND", `Memory path '${options.path}' not found`, backend);
}
throw new MemoryBackendError("READ_FAILED", `Failed to read memory path '${options.path}': ${(err as Error).message}`, backend);
}
const lines = content.split("\n");
const startLine = Math.max(1, Math.floor(options.startLine ?? 1));
const requestedCount = Math.max(1, Math.floor(options.lineCount ?? DEFAULT_MEMORY_GET_LINES));
const lineCount = Math.min(requestedCount, MAX_MEMORY_GET_LINES);
const startIndex = Math.min(startLine - 1, lines.length);
const endIndex = Math.min(startIndex + lineCount, lines.length);
return {
path: displayPath,
content: lines.slice(startIndex, endIndex).join("\n"),
startLine,
endLine: endIndex,
totalLines: lines.length,
backend,
};
}
async function listMemoryFiles(rootDir: string): Promise<Array<{ absPath: string; displayPath: string }>> {
const files: Array<{ absPath: string; displayPath: string }> = [];
const workspacePath = memoryWorkspacePath(rootDir);
const longTerm = memoryLongTermPath(rootDir);
if (existsSync(longTerm)) {
files.push({ absPath: longTerm, displayPath: `${MEMORY_WORKSPACE_PATH}/${MEMORY_LONG_TERM_FILENAME}` });
}
const dreams = memoryDreamsPath(rootDir);
if (existsSync(dreams)) {
files.push({ absPath: dreams, displayPath: `${MEMORY_WORKSPACE_PATH}/${MEMORY_DREAMS_FILENAME}` });
}
if (existsSync(workspacePath)) {
for (const entry of await readdir(workspacePath)) {
if (!DAILY_MEMORY_RE.test(entry)) continue;
const absPath = join(workspacePath, entry);
const fileStat = await stat(absPath);
if (fileStat.isFile()) {
files.push({ absPath, displayPath: `${MEMORY_WORKSPACE_PATH}/${entry}` });
}
}
}
const legacyPath = join(rootDir, LEGACY_MEMORY_FILE_PATH);
if (existsSync(legacyPath)) {
files.push({ absPath: legacyPath, displayPath: LEGACY_MEMORY_FILE_PATH });
}
return files;
}
function scoreSnippet(snippet: string, queryTerms: string[]): number {
const normalized = snippet.toLowerCase();
return queryTerms.reduce((score, term) => score + (normalized.includes(term) ? 1 : 0), 0);
}
async function searchMemoryFiles(rootDir: string, options: MemorySearchOptions, backend: string): Promise<MemorySearchResult[]> {
const queryTerms = options.query
.toLowerCase()
.split(/[^a-z0-9_-]+/i)
.map((term) => term.trim())
.filter((term) => term.length >= 2);
if (queryTerms.length === 0) {
return [];
}
const limit = Math.max(1, Math.min(options.limit ?? 5, 20));
const results: MemorySearchResult[] = [];
for (const file of await listMemoryFiles(rootDir)) {
const lines = (await readFile(file.absPath, "utf-8")).split("\n");
for (let index = 0; index < lines.length; index += 8) {
const chunkLines = lines.slice(index, index + 12);
const snippet = chunkLines.join("\n").trim();
if (!snippet) continue;
const score = scoreSnippet(snippet, queryTerms);
if (score === 0) continue;
results.push({
path: file.displayPath,
lineStart: index + 1,
lineEnd: Math.min(index + chunkLines.length, lines.length),
snippet: snippet.slice(0, MAX_MEMORY_SNIPPET_CHARS),
score,
backend,
});
}
}
return results
.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
.slice(0, limit);
}
async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Promise<MemorySearchResult[]> {
const command = "qmd";
const mode = "search";
const args = [mode, options.query, "--json"];
try {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
const { stdout } = await execFileAsync(command, args, {
cwd: rootDir,
timeout: 4000,
maxBuffer: 1024 * 1024,
});
const parsed = JSON.parse(stdout);
const rawResults = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : [];
return rawResults.slice(0, Math.max(1, Math.min(options.limit ?? 5, 20))).map((result: Record<string, unknown>, index: number) => ({
path: String(result.path ?? result.file ?? `qmd/result-${index + 1}`),
lineStart: Number(result.lineStart ?? result.startLine ?? 1),
lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1),
snippet: String(result.snippet ?? result.text ?? result.content ?? "").slice(0, MAX_MEMORY_SNIPPET_CHARS),
score: Number(result.score ?? 1),
backend: "qmd",
})).filter((result: MemorySearchResult) => result.snippet.trim().length > 0);
} catch {
return [];
}
}
// ── Backend Registration ─────────────────────────────────────────────

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from "vitest";
import {
createMemoryDreamsAutomation,
DEFAULT_MEMORY_DREAMS_SCHEDULE,
MEMORY_DREAMS_SCHEDULE_NAME,
syncMemoryDreamsAutomation,
} from "./memory-dreams.js";
describe("memory-dreams automation", () => {
it("creates a scheduled dream processor automation with defaults", () => {
const automation = createMemoryDreamsAutomation({});
expect(automation.name).toBe(MEMORY_DREAMS_SCHEDULE_NAME);
expect(automation.cronExpression).toBe(DEFAULT_MEMORY_DREAMS_SCHEDULE);
expect(automation.steps).toHaveLength(1);
expect(automation.steps![0].id).toBe("memory-dream-processor");
expect(automation.steps![0].prompt).toContain(".fusion/memory/DREAMS.md");
expect(automation.steps![0].prompt).toContain(".fusion/memory/MEMORY.md");
});
it("uses custom schedule and model when provided", () => {
const automation = createMemoryDreamsAutomation(
{ memoryDreamsSchedule: "0 */8 * * *" },
"anthropic",
"claude-sonnet-4-5",
);
expect(automation.cronExpression).toBe("0 */8 * * *");
expect(automation.steps![0].modelProvider).toBe("anthropic");
expect(automation.steps![0].modelId).toBe("claude-sonnet-4-5");
});
it("deletes an existing automation when dreams are disabled", async () => {
const automationStore = {
listSchedules: vi.fn().mockResolvedValue([{ id: "dreams-1", name: MEMORY_DREAMS_SCHEDULE_NAME }]),
deleteSchedule: vi.fn().mockResolvedValue(undefined),
};
await syncMemoryDreamsAutomation(automationStore as any, { memoryDreamsEnabled: false });
expect(automationStore.deleteSchedule).toHaveBeenCalledWith("dreams-1");
});
it("creates an automation when dreams are enabled", async () => {
const automationStore = {
listSchedules: vi.fn().mockResolvedValue([]),
createSchedule: vi.fn().mockImplementation(async (input) => ({ id: "dreams-1", ...input })),
};
const result = await syncMemoryDreamsAutomation(automationStore as any, { memoryDreamsEnabled: true });
expect(automationStore.createSchedule).toHaveBeenCalledWith(
expect.objectContaining({ name: MEMORY_DREAMS_SCHEDULE_NAME }),
);
expect(result?.id).toBe("dreams-1");
});
});

View File

@@ -0,0 +1,183 @@
import { appendFile, readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import {
dailyMemoryPath,
ensureOpenClawMemoryFiles,
memoryDreamsPath,
memoryLongTermPath,
} from "./memory-backend.js";
import type { ScheduledTaskCreateInput } from "./automation.js";
import type { ProjectSettings } from "./types.js";
export const MEMORY_DREAMS_SCHEDULE_NAME = "Memory Dreams";
export const DEFAULT_MEMORY_DREAMS_SCHEDULE = "0 4 * * *";
export interface DreamProcessorResult {
dreams: string;
longTermUpdates: string;
}
export type DreamPromptExecutor = (prompt: string) => Promise<string>;
export function buildDreamProcessingPrompt(input: {
date: string;
longTermMemory: string;
dailyMemory: string;
previousDreams: string;
}): string {
return `You are processing project memory in an OpenClaw-style memory system.
Read today's daily notes and existing long-term memory. Produce:
1. DREAMS: synthesized patterns, open loops, contradictions, and emerging themes.
2. LONG_TERM_UPDATES: only durable conventions, decisions, pitfalls, or constraints worth keeping.
Rules:
- Do not copy task logs or changelog entries.
- Do not invent facts not present in the input.
- Keep output concise and actionable.
- Return exactly these Markdown headings:
## DREAMS
## LONG_TERM_UPDATES
Date: ${input.date}
## Existing Long-Term Memory
${input.longTermMemory || "(empty)"}
## Previous Dreams
${input.previousDreams || "(empty)"}
## Daily Notes
${input.dailyMemory || "(empty)"}
`;
}
export function extractDreamProcessorResult(output: string): DreamProcessorResult {
const dreamsMatch = output.match(/## DREAMS\s*([\s\S]*?)(?=## LONG_TERM_UPDATES|$)/i);
const updatesMatch = output.match(/## LONG_TERM_UPDATES\s*([\s\S]*?)$/i);
return {
dreams: dreamsMatch?.[1]?.trim() ?? "",
longTermUpdates: updatesMatch?.[1]?.trim() ?? "",
};
}
async function readIfExists(path: string): Promise<string> {
if (!existsSync(path)) {
return "";
}
return readFile(path, "utf-8");
}
export async function processMemoryDreams(
rootDir: string,
executePrompt: DreamPromptExecutor,
date = new Date(),
): Promise<DreamProcessorResult> {
await ensureOpenClawMemoryFiles(rootDir, date);
const dateKey = date.toISOString().slice(0, 10);
const longTermPath = memoryLongTermPath(rootDir);
const dreamsPath = memoryDreamsPath(rootDir);
const dailyPath = dailyMemoryPath(rootDir, date);
const prompt = buildDreamProcessingPrompt({
date: dateKey,
longTermMemory: await readIfExists(longTermPath),
previousDreams: await readIfExists(dreamsPath),
dailyMemory: await readIfExists(dailyPath),
});
const result = extractDreamProcessorResult(await executePrompt(prompt));
if (result.dreams) {
await appendFile(dreamsPath, `\n## ${dateKey}\n\n${result.dreams}\n`, "utf-8");
}
if (result.longTermUpdates) {
await appendFile(longTermPath, `\n## Dream Updates ${dateKey}\n\n${result.longTermUpdates}\n`, "utf-8");
}
await writeFile(dailyPath, `# Daily Memory ${dateKey}\n\n<!-- Processed into dreams on ${new Date().toISOString()} -->\n`, "utf-8");
return result;
}
export function createMemoryDreamsAutomation(
settings: Partial<ProjectSettings>,
modelProvider?: string,
modelId?: string,
): ScheduledTaskCreateInput {
const schedule = settings.memoryDreamsSchedule ?? DEFAULT_MEMORY_DREAMS_SCHEDULE;
const prompt = `You are the Memory Dream Processor for an OpenClaw-style project memory system.
## Your Task
1. Read today's daily notes from \`.fusion/memory/YYYY-MM-DD.md\`.
2. Read existing dreams from \`.fusion/memory/DREAMS.md\`.
3. Read long-term memory from \`.fusion/memory/MEMORY.md\`.
4. Append a dated synthesis to \`.fusion/memory/DREAMS.md\` with patterns, open loops, contradictions, and emerging themes.
5. Append only durable conventions, decisions, pitfalls, or constraints to \`.fusion/memory/MEMORY.md\`.
6. Reset today's daily note to a short processed marker after successful synthesis.
## Rules
- Do not copy task logs or changelog entries into long-term memory.
- Do not invent facts.
- Keep dreams useful for future agents, not a transcript of the day.
- Preserve the three-layer model: daily notes are raw, DREAMS.md is synthesis, MEMORY.md is curated durable knowledge.`;
return {
name: MEMORY_DREAMS_SCHEDULE_NAME,
description: "Synthesizes daily memory notes into dreams and promotes durable lessons to long-term memory",
scheduleType: "custom",
cronExpression: schedule,
command: "",
enabled: true,
steps: [
{
id: "memory-dream-processor",
type: "ai-prompt",
name: "Process Memory Dreams",
prompt,
...(modelProvider && modelId ? { modelProvider, modelId } : {}),
timeoutMs: 120_000,
},
],
};
}
export async function syncMemoryDreamsAutomation(
automationStore: import("./automation-store.js").AutomationStore,
settings: Partial<ProjectSettings>,
): Promise<import("./automation.js").ScheduledTask | undefined> {
const { AutomationStore } = await import("./automation-store.js");
const schedules = await automationStore.listSchedules();
const existingSchedule = schedules.find((schedule) => schedule.name === MEMORY_DREAMS_SCHEDULE_NAME);
if (!settings.memoryDreamsEnabled) {
if (existingSchedule) {
await automationStore.deleteSchedule(existingSchedule.id);
}
return undefined;
}
const schedule = settings.memoryDreamsSchedule ?? DEFAULT_MEMORY_DREAMS_SCHEDULE;
if (!AutomationStore.isValidCron(schedule)) {
throw new Error(`Invalid memory dreams schedule: ${schedule}`);
}
const input = createMemoryDreamsAutomation(settings);
if (existingSchedule) {
return automationStore.updateSchedule(existingSchedule.id, {
scheduleType: "custom",
cronExpression: schedule,
command: input.command,
steps: input.steps,
enabled: true,
});
}
return automationStore.createSchedule(input);
}

View File

@@ -19,9 +19,17 @@
* - The memory instruction templates used by triage and executor prompts
*/
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
ensureOpenClawMemoryFiles,
memoryLongTermPath,
type MemorySearchOptions,
type MemorySearchResult,
type MemoryGetOptions,
type MemoryGetResult,
} from "./memory-backend.js";
// ── Constants ────────────────────────────────────────────────────────
@@ -81,6 +89,7 @@ export function getDefaultMemoryScaffold(): string {
export async function ensureMemoryFile(rootDir: string): Promise<boolean> {
const filePath = memoryFilePath(rootDir);
if (existsSync(filePath)) {
await ensureOpenClawMemoryFiles(rootDir);
return false;
}
@@ -90,6 +99,7 @@ export async function ensureMemoryFile(rootDir: string): Promise<boolean> {
}
await writeFile(filePath, getDefaultMemoryScaffold(), "utf-8");
await ensureOpenClawMemoryFiles(rootDir);
return true;
}
@@ -229,12 +239,30 @@ export async function ensureMemoryFileWithBackend(
if (backend.exists) {
const exists = await backend.exists(rootDir);
if (exists) {
if (backend.capabilities.writable) {
await ensureOpenClawMemoryFiles(rootDir);
if (!existsSync(memoryFilePath(rootDir))) {
const existingContent = await readProjectMemory(rootDir);
if (existingContent) {
await backend.write(rootDir, existingContent);
}
}
}
return false; // Memory already exists, don't overwrite
}
} else {
// Fall back to direct file check
const filePath = memoryFilePath(rootDir);
if (existsSync(filePath)) {
if (backend.capabilities.writable) {
await ensureOpenClawMemoryFiles(rootDir);
if (!existsSync(memoryFilePath(rootDir))) {
const existingContent = await readProjectMemory(rootDir);
if (existingContent) {
await backend.write(rootDir, existingContent);
}
}
}
return false; // Memory already exists, don't overwrite
}
}
@@ -245,6 +273,13 @@ export async function ensureMemoryFileWithBackend(
await mkdir(dir, { recursive: true });
}
// OpenClaw-style memory layers are always bootstrapped for writable memory
// backends. The legacy `.fusion/memory.md` file remains as a compatibility
// source, but new writes go to `.fusion/memory/MEMORY.md`.
if (backend.capabilities.writable) {
await ensureOpenClawMemoryFiles(rootDir);
}
// Try to write using the backend
try {
const result = await backend.write(rootDir, getDefaultMemoryScaffold());
@@ -284,6 +319,32 @@ export async function readProjectMemoryWithBackend(
}
}
export async function searchProjectMemory(
rootDir: string,
options: MemorySearchOptions,
settings?: MemorySettings,
): Promise<MemorySearchResult[]> {
const { resolveMemoryBackend } = await getMemoryBackendUtils();
const backend = resolveMemoryBackend(settings);
if (!backend.search) {
return [];
}
return backend.search(rootDir, options);
}
export async function getProjectMemory(
rootDir: string,
options: MemoryGetOptions,
settings?: MemorySettings,
): Promise<MemoryGetResult> {
const { resolveMemoryBackend } = await getMemoryBackendUtils();
const backend = resolveMemoryBackend(settings);
if (!backend.get) {
throw new Error(`Memory backend '${backend.type}' does not support memory_get`);
}
return backend.get(rootDir, options);
}
// ── Memory Instructions for Prompts ──────────────────────────────────
/**
@@ -332,14 +393,17 @@ This project has a memory system that stores durable project learnings.
return `
## Project Memory
This project has a memory file at \`.fusion/memory.md\` that stores durable project learnings.
This project has OpenClaw-style memory files:
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running context
- Legacy fallback: \`.fusion/memory.md\`
**Before writing the specification:**
1. Read \`.fusion/memory.md\` using the read tool
2. Consult the architecture, conventions, pitfalls, and context sections
1. Use \`memory_search\` first for task-relevant context
2. Use \`memory_get\` only for specific memory files/line ranges returned by search
3. Incorporate relevant learnings into your specification — reference actual patterns, constraints, and conventions documented there
**If the memory file contains useful context for this task, reference it in the specification.** For example, if the memory documents that the project uses a specific pattern for API routes, ensure the specification follows that pattern.
Do not read all memory or read \`.fusion/memory.md\` directly by default. If memory is irrelevant, skip it.
`;
}
@@ -407,27 +471,33 @@ This project has a memory system that stores durable project learnings.
return `
## Project Memory
This project has a memory file at \`.fusion/memory.md\` that stores durable project learnings accumulated from past task runs.
This project has OpenClaw-style memory files:
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running observations and open loops
- Legacy fallback: \`.fusion/memory.md\`
**At the start of execution:**
1. Read \`.fusion/memory.md\` using the read tool
2. Review the architecture, conventions, pitfalls, and context sections
3. Apply these learnings to your implementation — follow documented patterns and avoid known pitfalls
1. Use \`memory_search\` first for task-relevant context
2. Use \`memory_get\` only for specific memory files/line ranges returned by search
3. Apply relevant learnings to your implementation — follow documented patterns and avoid known pitfalls
4. Do not load all memory or read \`.fusion/memory.md\` directly by default. Skip memory reads when memory is irrelevant or context is tight.
**At the end of execution (before calling \`task_done()\`):**
1. Review what you learned during this task that would genuinely benefit future runs
2. **If nothing durable was learned, skip the memory update entirely** — do not append trivial or task-specific notes
3. Only write when you have genuinely durable, reusable insights such as:
2. Write durable decisions, conventions, and pitfalls to \`.fusion/memory/MEMORY.md\`
3. Write running observations, unresolved context, and open loops to today's \`.fusion/memory/YYYY-MM-DD.md\`
4. **If nothing durable was learned, skip the memory update entirely** — do not append trivial or task-specific notes
5. Only write when you have genuinely durable, reusable insights such as:
- New architectural patterns or module boundaries discovered
- Conventions or standards that should be followed
- Pitfalls or anti-patterns to avoid in future work
- Important constraints or context that affects implementation decisions
4. **Avoid** writing task-specific trivia such as:
6. **Avoid** writing task-specific trivia such as:
- Per-task implementation logs or changelog entries
- Transient failures resolved without broader lessons
- One-off file paths, variable names, or minor code changes
- Notes about what you did rather than what future agents should know
5. **Consolidate when possible**: If an existing entry already covers a concept, update or refine it rather than adding a duplicate. Delete entries that are no longer accurate.
7. **Consolidate when possible**: If an existing entry already covers a concept, update or refine it rather than adding a duplicate. Delete entries that are no longer accurate.
**Format for additions:** Add bullet points under the relevant section heading:
- Use \`- \` prefix for list items
@@ -469,7 +539,15 @@ This project has a memory system that stores durable project learnings accumulat
* @returns The memory file content, or empty string if not found.
*/
export async function readProjectMemory(rootDir: string): Promise<string> {
const longTermPath = memoryLongTermPath(rootDir);
const filePath = memoryFilePath(rootDir);
if (existsSync(longTermPath) && existsSync(filePath)) {
const [longTermStat, legacyStat] = await Promise.all([stat(longTermPath), stat(filePath)]);
return readFile(legacyStat.mtimeMs > longTermStat.mtimeMs ? filePath : longTermPath, "utf-8");
}
if (existsSync(longTermPath)) {
return readFile(longTermPath, "utf-8");
}
if (!existsSync(filePath)) {
return "";
}

View File

@@ -130,6 +130,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50_000,
memoryAutoSummarizeSchedule: "0 3 * * *",
memoryDreamsEnabled: false,
memoryDreamsSchedule: "0 4 * * *",
tokenCap: undefined,
runStepsInNewSessions: false,
maxParallelSteps: 2,

View File

@@ -1285,6 +1285,13 @@ export interface ProjectSettings {
* memoryAutoSummarizeEnabled is true.
* Default: "0 3 * * *" (daily at 3 AM, offset from insight extraction at 2 AM). */
memoryAutoSummarizeSchedule?: string;
/** When true, daily memory notes are periodically synthesized into DREAMS.md
* and durable lessons are promoted into `.fusion/memory/MEMORY.md`.
* Default: false. */
memoryDreamsEnabled?: boolean;
/** Cron expression for dream processing. Only used when memoryDreamsEnabled
* is true. Default: "0 4 * * *" (daily at 4 AM, after long-term compaction). */
memoryDreamsSchedule?: string;
/** Maximum token count before auto-compact triggers. When undefined, compact
* only on overflow errors. When set, the engine monitors token usage after
* each prompt and proactively compacts context when the token count reaches