fix(FN-000): rebuild layered memory system
This commit is contained in:
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
57
packages/core/src/memory-dreams.test.ts
Normal file
57
packages/core/src/memory-dreams.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
183
packages/core/src/memory-dreams.ts
Normal file
183
packages/core/src/memory-dreams.ts
Normal 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);
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user