feat(FN-2087): finalize canonical memory path migration
- Remove legacy .fusion/memory.md fallback references and normalize prompts/docs to .fusion/memory/MEMORY.md - Stop legacy mirror writes and fallback reads in core memory backend and project memory flows - Update engine worktree boundary checks and tests for canonical memory file handling - Align dashboard memory/settings surfaces and route tests with canonical memory behavior - Add model-favorites persistence test coverage for mission interview and new agent dialogs
This commit is contained in:
@@ -38,7 +38,7 @@ describe("test-project fixture", () => {
|
||||
expect(existsSync(join(fixture.rootDir, ".fusion", "fusion.db"))).toBe(true);
|
||||
expect(existsSync(join(fixture.rootDir, ".fusion", "config.json"))).toBe(true);
|
||||
expect(existsSync(join(fixture.rootDir, ".fusion", "tasks"))).toBe(true);
|
||||
expect(existsSync(join(fixture.rootDir, ".fusion", "memory.md"))).toBe(true);
|
||||
expect(existsSync(join(fixture.rootDir, ".fusion", "memory", "MEMORY.md"))).toBe(true);
|
||||
|
||||
const configRaw = await readFile(join(fixture.rootDir, ".fusion", "config.json"), "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
|
||||
@@ -154,7 +154,7 @@ describe("resolveAgentPrompt", () => {
|
||||
|
||||
it("built-in executor prompt mentions memory exception", () => {
|
||||
const result = resolveAgentPrompt("executor");
|
||||
expect(result).toContain(".fusion/memory/");
|
||||
expect(result).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("built-in executor prompt mentions attachments exception", () => {
|
||||
@@ -183,7 +183,7 @@ describe("resolveAgentPrompt", () => {
|
||||
};
|
||||
|
||||
const result = resolveAgentPrompt("executor", config);
|
||||
expect(result).toContain(".fusion/memory/");
|
||||
expect(result).toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("senior-engineer prompt mentions attachments exception", () => {
|
||||
|
||||
@@ -108,7 +108,7 @@ model, read-only access) to independently assess your work.
|
||||
You are running in an **isolated git worktree**. This means:
|
||||
|
||||
- **All code changes must be made inside the current worktree directory.** Do not modify files outside the worktree — the worktree is your isolated execution environment.
|
||||
- **Exception — Project memory:** You MAY read and write to files under .fusion/memory/ at the project root (MEMORY.md for durable learnings, YYYY-MM-DD.md for daily notes) to save durable project learnings.
|
||||
- **Exception — Project memory:** You MAY read and write to .fusion/memory/MEMORY.md at the project root to save durable project learnings (architecture patterns, conventions, pitfalls).
|
||||
- **Exception — Task attachments:** You MAY read files under .fusion/tasks/{taskId}/attachments/ at the project root for context screenshots and documents attached to this task.
|
||||
- **Shell commands** run inside the worktree by default. Avoid using cd to navigate outside the worktree.
|
||||
|
||||
@@ -519,7 +519,7 @@ model, read-only access) to independently assess your work.
|
||||
You are running in an **isolated git worktree**. This means:
|
||||
|
||||
- **All code changes must be made inside the current worktree directory.** Do not modify files outside the worktree — the worktree is your isolated execution environment.
|
||||
- **Exception — Project memory:** You MAY read and write to files under .fusion/memory/ at the project root (MEMORY.md for durable learnings, YYYY-MM-DD.md for daily notes) to save durable project learnings.
|
||||
- **Exception — Project memory:** You MAY read and write to .fusion/memory/MEMORY.md at the project root to save durable project learnings (architecture patterns, conventions, pitfalls).
|
||||
- **Exception — Task attachments:** You MAY read files under .fusion/tasks/{taskId}/attachments/ at the project root for context screenshots and documents attached to this task.
|
||||
- **Shell commands** run inside the worktree by default. Avoid using cd to navigate outside the worktree.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
memoryExists,
|
||||
MEMORY_BACKEND_SETTINGS_KEYS,
|
||||
DEFAULT_MEMORY_BACKEND,
|
||||
LEGACY_MEMORY_FILE_PATH,
|
||||
QMD_INSTALL_COMMAND,
|
||||
buildQmdSearchArgs,
|
||||
buildQmdCollectionAddArgs,
|
||||
@@ -28,12 +29,18 @@ import {
|
||||
qmdMemoryCollectionName,
|
||||
QMD_REFRESH_INTERVAL_MS,
|
||||
shouldSkipBackgroundQmdRefresh,
|
||||
listProjectMemoryFiles,
|
||||
readProjectMemoryFile,
|
||||
writeProjectMemoryFile,
|
||||
} from "./memory-backend.js";
|
||||
import type { MemoryBackend } from "./memory-backend.js";
|
||||
|
||||
describe("memory-backend", () => {
|
||||
let tempDir: string;
|
||||
|
||||
const longTermMemoryPath = (rootDir: string) => join(rootDir, ".fusion", "memory", "MEMORY.md");
|
||||
const legacyMemoryPath = (rootDir: string) => join(rootDir, ".fusion", "memory.md");
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-backend-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
@@ -78,7 +85,7 @@ describe("memory-backend", () => {
|
||||
|
||||
it("should have human-readable name", () => {
|
||||
const backend = new FileMemoryBackend();
|
||||
expect(backend.name).toBe("File (.fusion/memory.md)");
|
||||
expect(backend.name).toBe("File (.fusion/memory/MEMORY.md)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +119,8 @@ describe("memory-backend", () => {
|
||||
});
|
||||
|
||||
it("should return content when file exists", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "# Project Memory\n\nTest content", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
@@ -123,6 +131,16 @@ describe("memory-backend", () => {
|
||||
expect(result.backend).toBe("file");
|
||||
});
|
||||
|
||||
it("ignores legacy memory.md when long-term memory is missing", async () => {
|
||||
writeFileSync(legacyMemoryPath(tempDir), "legacy content", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
const result = await backend.read(tempDir);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.exists).toBe(false);
|
||||
});
|
||||
|
||||
// Note: Testing read failure is complex in ESM because we can't easily mock
|
||||
// the fs/promises module. The error handling is tested through integration tests
|
||||
// and the MemoryBackendError class tests above.
|
||||
@@ -137,9 +155,10 @@ describe("memory-backend", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backend).toBe("file");
|
||||
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
expect(readFileSync(memoryPath, "utf-8")).toBe("# Project Memory\n\nNew content");
|
||||
expect(existsSync(legacyMemoryPath(tempDir))).toBe(false);
|
||||
});
|
||||
|
||||
it("should create .fusion directory if missing", async () => {
|
||||
@@ -149,12 +168,13 @@ describe("memory-backend", () => {
|
||||
const backend = new FileMemoryBackend();
|
||||
await backend.write(newDir, "# Memory");
|
||||
|
||||
const memoryPath = join(newDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(newDir);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("should overwrite existing content", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
writeFileSync(memoryPath, "Original content", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
@@ -165,17 +185,17 @@ describe("memory-backend", () => {
|
||||
|
||||
it("should not leave temp files on error", async () => {
|
||||
// This test verifies atomic write behavior
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
writeFileSync(memoryPath, "Original", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
|
||||
|
||||
// Write should succeed, temp file should be cleaned up
|
||||
await backend.write(tempDir, "Updated");
|
||||
|
||||
|
||||
// No temp files should exist
|
||||
const fusionDir = join(tempDir, ".fusion");
|
||||
const files = require("node:fs").readdirSync(fusionDir);
|
||||
const files = require("node:fs").readdirSync(join(tempDir, ".fusion", "memory"));
|
||||
expect(files.filter((f: string) => f.endsWith(".tmp"))).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -188,13 +208,53 @@ describe("memory-backend", () => {
|
||||
});
|
||||
|
||||
it("should return true when file exists", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "Content", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
const result = await backend.exists(tempDir);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when only legacy memory.md exists", async () => {
|
||||
writeFileSync(legacyMemoryPath(tempDir), "legacy content", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
await expect(backend.exists(tempDir)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project memory file APIs", () => {
|
||||
it("writeProjectMemoryFile writes long-term memory without legacy mirror", async () => {
|
||||
await writeProjectMemoryFile(tempDir, ".fusion/memory/MEMORY.md", "layered content");
|
||||
|
||||
expect(readFileSync(longTermMemoryPath(tempDir), "utf-8")).toBe("layered content");
|
||||
expect(existsSync(legacyMemoryPath(tempDir))).toBe(false);
|
||||
});
|
||||
|
||||
it("readProjectMemoryFile rejects legacy memory.md paths", async () => {
|
||||
await expect(readProjectMemoryFile(tempDir, { path: LEGACY_MEMORY_FILE_PATH })).rejects.toThrow(MemoryBackendError);
|
||||
});
|
||||
|
||||
it("listProjectMemoryFiles excludes legacy memory.md entries", async () => {
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
writeFileSync(longTermMemoryPath(tempDir), "# Memory\n\nLong-term", "utf-8");
|
||||
writeFileSync(legacyMemoryPath(tempDir), "# Memory\n\nLegacy", "utf-8");
|
||||
|
||||
const files = await listProjectMemoryFiles(tempDir);
|
||||
expect(files.some((file) => file.path === LEGACY_MEMORY_FILE_PATH)).toBe(false);
|
||||
});
|
||||
|
||||
it("search ignores legacy memory.md content", async () => {
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
writeFileSync(longTermMemoryPath(tempDir), "# Memory\n\nDurable conventions", "utf-8");
|
||||
writeFileSync(legacyMemoryPath(tempDir), "legacy-only-token", "utf-8");
|
||||
|
||||
const backend = new FileMemoryBackend();
|
||||
const results = await backend.search(tempDir, { query: "legacy-only-token" });
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,7 +354,8 @@ describe("memory-backend", () => {
|
||||
|
||||
describe("read", () => {
|
||||
it("should read memory from filesystem and return qmd backend identifier", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "# Project Memory\n\nTest content", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
@@ -315,7 +376,8 @@ describe("memory-backend", () => {
|
||||
});
|
||||
|
||||
it("should return empty content for empty file", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
@@ -325,6 +387,16 @@ describe("memory-backend", () => {
|
||||
expect(result.exists).toBe(true); // File exists, just empty
|
||||
expect(result.backend).toBe("qmd");
|
||||
});
|
||||
|
||||
it("ignores legacy memory.md when long-term memory is missing", async () => {
|
||||
writeFileSync(legacyMemoryPath(tempDir), "legacy content", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
const result = await backend.read(tempDir);
|
||||
|
||||
expect(result.content).toBe("");
|
||||
expect(result.exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("write", () => {
|
||||
@@ -336,13 +408,14 @@ describe("memory-backend", () => {
|
||||
expect(result.backend).toBe("qmd");
|
||||
|
||||
// Verify file was actually written
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
expect(readFileSync(memoryPath, "utf-8")).toBe("# Memory\n\nContent");
|
||||
});
|
||||
|
||||
it("should overwrite existing content", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
writeFileSync(memoryPath, "Original content", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
@@ -358,7 +431,7 @@ describe("memory-backend", () => {
|
||||
const backend = new QmdMemoryBackend();
|
||||
await backend.write(newDir, "# Memory");
|
||||
|
||||
const memoryPath = join(newDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(newDir);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -375,7 +448,8 @@ describe("memory-backend", () => {
|
||||
|
||||
describe("exists", () => {
|
||||
it("should return true when memory file exists", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "Content", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
@@ -391,8 +465,16 @@ describe("memory-backend", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when only legacy memory.md exists", async () => {
|
||||
writeFileSync(legacyMemoryPath(tempDir), "legacy content", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
await expect(backend.exists(tempDir)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for empty file", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "", "utf-8");
|
||||
|
||||
const backend = new QmdMemoryBackend();
|
||||
@@ -705,7 +787,8 @@ describe("memory-backend", () => {
|
||||
|
||||
describe("readMemory", () => {
|
||||
it("should read using qmd backend by default", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "Test memory content", "utf-8");
|
||||
|
||||
const result = await readMemory(tempDir);
|
||||
@@ -735,7 +818,7 @@ describe("memory-backend", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.backend).toBe("qmd");
|
||||
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
expect(readFileSync(memoryPath, "utf-8")).toBe("# Memory\n\nContent");
|
||||
});
|
||||
|
||||
@@ -765,7 +848,8 @@ describe("memory-backend", () => {
|
||||
});
|
||||
|
||||
it("should return true when file exists", async () => {
|
||||
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
const memoryPath = longTermMemoryPath(tempDir);
|
||||
writeFileSync(memoryPath, "Content", "utf-8");
|
||||
|
||||
const result = await memoryExists(tempDir);
|
||||
@@ -786,7 +870,7 @@ describe("memory-backend", () => {
|
||||
it("should handle backend switching via settings", async () => {
|
||||
// First, write with file backend
|
||||
await writeMemory(tempDir, "Initial content");
|
||||
expect(existsSync(join(tempDir, ".fusion", "memory.md"))).toBe(true);
|
||||
expect(existsSync(longTermMemoryPath(tempDir))).toBe(true);
|
||||
|
||||
// Read with readonly backend (should still find the file even though it's read-only)
|
||||
// Note: readMemory doesn't check file existence for readonly - it just returns empty
|
||||
@@ -800,7 +884,7 @@ describe("memory-backend", () => {
|
||||
await writeMemory(tempDir, "Persistent content");
|
||||
|
||||
// File should exist
|
||||
expect(existsSync(join(tempDir, ".fusion", "memory.md"))).toBe(true);
|
||||
expect(existsSync(longTermMemoryPath(tempDir))).toBe(true);
|
||||
|
||||
// Read back with file backend
|
||||
const fileSettings = { [MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE]: "file" };
|
||||
@@ -942,7 +1026,7 @@ describe("memory-backend", () => {
|
||||
|
||||
const result = await readMemory(nestedDir);
|
||||
expect(result.content).toBe("Nested content");
|
||||
expect(existsSync(join(nestedDir, ".fusion", "memory.md"))).toBe(true);
|
||||
expect(existsSync(longTermMemoryPath(nestedDir))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,7 +114,7 @@ export interface MemorySearchResult {
|
||||
export interface MemoryFileInfo {
|
||||
path: string;
|
||||
label: string;
|
||||
layer: "long-term" | "daily" | "dreams" | "legacy";
|
||||
layer: "long-term" | "daily" | "dreams";
|
||||
size: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -210,12 +210,12 @@ const backendRegistry = new Map<string, MemoryBackend>();
|
||||
/**
|
||||
* File-based memory backend.
|
||||
*
|
||||
* Stores project memory in `.fusion/memory.md` at the project root.
|
||||
* Preserves the legacy `.fusion/memory.md` storage path when explicitly selected.
|
||||
* Stores project memory in `.fusion/memory/MEMORY.md` at the project root.
|
||||
* Legacy `.fusion/memory.md` is only used by migration bootstrap when upgrading.
|
||||
*/
|
||||
export class FileMemoryBackend implements MemoryBackend {
|
||||
readonly type = "file";
|
||||
readonly name = "File (.fusion/memory.md)";
|
||||
readonly name = "File (.fusion/memory/MEMORY.md)";
|
||||
readonly capabilities: MemoryBackendCapabilities = {
|
||||
readable: true,
|
||||
writable: true,
|
||||
@@ -224,25 +224,12 @@ export class FileMemoryBackend implements MemoryBackend {
|
||||
persistent: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the absolute path to the memory file.
|
||||
*/
|
||||
private getFilePath(rootDir: string): string {
|
||||
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 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;
|
||||
}
|
||||
const filePath = this.getLongTermPath(rootDir);
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
return {
|
||||
@@ -269,17 +256,12 @@ export class FileMemoryBackend implements MemoryBackend {
|
||||
async write(rootDir: string, content: string): Promise<MemoryWriteResult> {
|
||||
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";
|
||||
@@ -289,11 +271,6 @@ 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,
|
||||
@@ -308,11 +285,8 @@ export class FileMemoryBackend implements MemoryBackend {
|
||||
}
|
||||
|
||||
async exists(rootDir: string): Promise<boolean> {
|
||||
const filePath = existsSync(this.getLongTermPath(rootDir))
|
||||
? this.getLongTermPath(rootDir)
|
||||
: this.getFilePath(rootDir);
|
||||
try {
|
||||
await access(filePath, constants.R_OK);
|
||||
await access(this.getLongTermPath(rootDir), constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -377,17 +351,17 @@ export class ReadOnlyMemoryBackend implements MemoryBackend {
|
||||
/**
|
||||
* QMD (qmd index/query integration) memory backend.
|
||||
*
|
||||
* Stores project memory in `.fusion/memory.md` so it can be indexed and queried
|
||||
* Stores project memory in `.fusion/memory/MEMORY.md` so it can be indexed and queried
|
||||
* by the external `qmd` tool. Read/write operations use direct filesystem access
|
||||
* for reliability. The `qmd` tool can be configured separately to watch and index
|
||||
* the memory file for advanced querying capabilities.
|
||||
* layered memory files for advanced querying capabilities.
|
||||
*
|
||||
* **Capabilities:**
|
||||
* - readable: true
|
||||
* - writable: true
|
||||
* - supportsAtomicWrite: false (QMD indexing is async/external)
|
||||
* - hasConflictResolution: false
|
||||
* - persistent: true (memory file persists in `.fusion/memory.md`)
|
||||
* - persistent: true (memory files persist in `.fusion/memory/`)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
@@ -587,7 +561,6 @@ export async function ensureOpenClawMemoryFiles(rootDir: string, date = new Date
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -595,7 +568,6 @@ 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")}`;
|
||||
}
|
||||
|
||||
@@ -618,7 +590,6 @@ export async function listProjectMemoryFiles(rootDir: string, date = new Date())
|
||||
"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));
|
||||
}
|
||||
@@ -641,20 +612,13 @@ export async function readProjectMemoryFileContent(rootDir: string, path: string
|
||||
}
|
||||
|
||||
export async function writeProjectMemoryFile(rootDir: string, path: string, content: string): Promise<MemoryWriteResult> {
|
||||
const { absPath, displayPath } = resolveMemoryFilePath(rootDir, path);
|
||||
const { absPath } = 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" };
|
||||
}
|
||||
|
||||
@@ -679,9 +643,6 @@ function normalizeMemoryRequestPath(rawPath: string): string {
|
||||
) {
|
||||
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)}`;
|
||||
}
|
||||
@@ -693,7 +654,7 @@ function normalizeMemoryRequestPath(rawPath: string): string {
|
||||
}
|
||||
throw new MemoryBackendError(
|
||||
"UNSUPPORTED",
|
||||
`Memory path '${rawPath}' is outside allowed files: MEMORY.md, DREAMS.md, memory/YYYY-MM-DD.md, .fusion/memory.md`,
|
||||
`Memory path '${rawPath}' is outside allowed files: MEMORY.md, DREAMS.md, memory/YYYY-MM-DD.md`,
|
||||
"memory",
|
||||
);
|
||||
}
|
||||
@@ -760,11 +721,6 @@ async function listMemoryFiles(rootDir: string): Promise<Array<{ absPath: string
|
||||
}
|
||||
}
|
||||
|
||||
const legacyPath = join(rootDir, LEGACY_MEMORY_FILE_PATH);
|
||||
if (existsSync(legacyPath)) {
|
||||
files.push({ absPath: legacyPath, displayPath: LEGACY_MEMORY_FILE_PATH });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("memory-insights", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-insights-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -612,7 +612,7 @@ describe("memory-insights", () => {
|
||||
|
||||
describe("constants", () => {
|
||||
it("should have correct file paths", () => {
|
||||
expect(MEMORY_WORKING_PATH).toBe(".fusion/memory.md");
|
||||
expect(MEMORY_WORKING_PATH).toBe(".fusion/memory/MEMORY.md");
|
||||
expect(MEMORY_INSIGHTS_PATH).toBe(".fusion/memory-insights.md");
|
||||
});
|
||||
|
||||
@@ -637,7 +637,7 @@ describe("memory-insights audit file operations", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-audit-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -697,7 +697,7 @@ describe("memory-insights run processing", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-run-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -1028,7 +1028,7 @@ describe("memory-insights audit generation", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-memory-audit-gen-test-"));
|
||||
await mkdir(join(tempDir, ".fusion"), { recursive: true });
|
||||
await mkdir(join(tempDir, ".fusion", "memory"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
*
|
||||
* ## Retention Policy
|
||||
*
|
||||
* - **Working memory** (`memory.md`): Manual/agent-maintained. No automatic
|
||||
* - **Working memory** (`MEMORY.md`): Manual/agent-maintained. No automatic
|
||||
* pruning — agents are expected to keep it relevant.
|
||||
*
|
||||
* - **Insights memory** (`memory-insights.md`): Only grows through
|
||||
@@ -66,14 +66,14 @@
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { ProjectSettings } from "./types.js";
|
||||
import type { ScheduledTaskCreateInput } from "./automation.js";
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/** Path to working memory relative to project root. */
|
||||
export const MEMORY_WORKING_PATH = ".fusion/memory.md";
|
||||
export const MEMORY_WORKING_PATH = ".fusion/memory/MEMORY.md";
|
||||
|
||||
/** Path to insights memory relative to project root. */
|
||||
export const MEMORY_INSIGHTS_PATH = ".fusion/memory-insights.md";
|
||||
@@ -225,7 +225,7 @@ export interface ProcessRunInput {
|
||||
// ── File I/O ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the working memory file (`memory.md`).
|
||||
* Read the working memory file (`MEMORY.md`).
|
||||
*
|
||||
* Returns an empty string if the file does not exist, enabling graceful
|
||||
* handling when FN-810's memory system is not yet in place.
|
||||
@@ -277,7 +277,7 @@ export async function writeInsightsMemory(rootDir: string, content: string): Pro
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the working memory file (`memory.md`).
|
||||
* Write the working memory file (`MEMORY.md`).
|
||||
*
|
||||
* Creates the `.fusion` directory if it does not exist.
|
||||
*
|
||||
@@ -286,7 +286,7 @@ export async function writeInsightsMemory(rootDir: string, content: string): Pro
|
||||
*/
|
||||
export async function writeWorkingMemory(rootDir: string, content: string): Promise<void> {
|
||||
const filePath = join(rootDir, MEMORY_WORKING_PATH);
|
||||
const dir = join(rootDir, ".fusion");
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
searchProjectMemory,
|
||||
resolveMemoryInstructionContext,
|
||||
} from "./project-memory.js";
|
||||
import { LEGACY_MEMORY_FILE_PATH } from "./memory-backend.js";
|
||||
|
||||
describe("project-memory", () => {
|
||||
let testDir: string;
|
||||
@@ -24,7 +25,7 @@ describe("project-memory", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `kb-memory-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
memoryPath = join(testDir, ".fusion", "memory.md");
|
||||
memoryPath = join(testDir, ".fusion", "memory", "MEMORY.md");
|
||||
// Create the test directory but not the .fusion subdirectory
|
||||
// Individual tests can create .fusion as needed
|
||||
await mkdir(testDir, { recursive: true });
|
||||
@@ -39,13 +40,13 @@ describe("project-memory", () => {
|
||||
|
||||
describe("MEMORY_FILE_PATH", () => {
|
||||
it("is a relative path under .fusion", () => {
|
||||
expect(MEMORY_FILE_PATH).toBe(".fusion/memory.md");
|
||||
expect(MEMORY_FILE_PATH).toBe(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memoryFilePath", () => {
|
||||
it("returns absolute path joining root and relative path", () => {
|
||||
expect(memoryFilePath("/project")).toBe("/project/.fusion/memory.md");
|
||||
expect(memoryFilePath("/project")).toBe("/project/.fusion/memory/MEMORY.md");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +116,7 @@ describe("project-memory", () => {
|
||||
// Manually edit the content
|
||||
const { writeFile } = await import("node:fs/promises");
|
||||
const customContent = "# Custom Memory\n\nMy custom content";
|
||||
await writeFile(memoryFilePath(testDir), customContent, "utf-8");
|
||||
await writeFile(memoryPath, customContent, "utf-8");
|
||||
|
||||
// Ensure again — should NOT overwrite
|
||||
const created = await ensureMemoryFile(testDir);
|
||||
@@ -154,6 +155,14 @@ describe("project-memory", () => {
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toContain("# Project Memory");
|
||||
});
|
||||
|
||||
it("returns empty content when only the legacy memory file exists", async () => {
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
await writeFile(join(testDir, LEGACY_MEMORY_FILE_PATH), "legacy content", "utf-8");
|
||||
|
||||
const content = await readProjectMemory(testDir);
|
||||
expect(content).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildTriageMemoryInstructions ─────────────────────────────────
|
||||
@@ -166,7 +175,7 @@ describe("project-memory", () => {
|
||||
|
||||
it("does not inject a raw memory file path by default", () => {
|
||||
const instructions = buildTriageMemoryInstructions(testDir);
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("instructs agent to search memory first", () => {
|
||||
@@ -191,7 +200,7 @@ describe("project-memory", () => {
|
||||
|
||||
it("does not inject a raw memory file path by default", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("instructs agent to search memory at start", () => {
|
||||
@@ -228,7 +237,7 @@ describe("project-memory", () => {
|
||||
|
||||
it("keeps qmd default path-agnostic", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).not.toContain("`.fusion/memory.md`");
|
||||
expect(instructions).not.toContain("`.fusion/memory/MEMORY.md`");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,7 +401,7 @@ describe("project-memory", () => {
|
||||
|
||||
it("returns memory content when using QMD backend", async () => {
|
||||
// Create the memory file directly (simulating prior creation)
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
await mkdir(join(testDir, ".fusion", "memory"), { recursive: true });
|
||||
await writeFile(memoryPath, "# QMD Memory\n\nSome content", "utf-8");
|
||||
|
||||
const settings = { memoryBackendType: "qmd" };
|
||||
@@ -451,7 +460,7 @@ describe("project-memory", () => {
|
||||
it("returns file backend context when explicitly set", () => {
|
||||
const ctx = resolveMemoryInstructionContext({ memoryBackendType: "file" });
|
||||
expect(ctx.backendType).toBe("file");
|
||||
expect(ctx.instructionPathHint).toBe(".fusion/memory.md");
|
||||
expect(ctx.instructionPathHint).toBe(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("returns readonly backend context", () => {
|
||||
@@ -482,10 +491,10 @@ describe("project-memory", () => {
|
||||
// ── Backend-aware buildTriageMemoryInstructions ─────────────────────────────────
|
||||
|
||||
describe("buildTriageMemoryInstructions with backend settings", () => {
|
||||
it("includes .fusion/memory.md for file backend", () => {
|
||||
it("includes .fusion/memory/MEMORY.md for file backend", () => {
|
||||
const settings = { memoryBackendType: "file" };
|
||||
const instructions = buildTriageMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain(".fusion/memory.md");
|
||||
expect(instructions).toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toContain("## Project Memory");
|
||||
});
|
||||
|
||||
@@ -496,17 +505,17 @@ describe("project-memory", () => {
|
||||
// Should NOT contain write/update directives
|
||||
expect(instructions).not.toMatch(/write|update/i);
|
||||
// Should NOT contain the specific file path
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
// Should instruct to consult memory
|
||||
expect(instructions).toMatch(/consult.*memory|memory.*context/i);
|
||||
});
|
||||
|
||||
it("does not include .fusion/memory.md for qmd backend", () => {
|
||||
it("does not include .fusion/memory/MEMORY.md for qmd backend", () => {
|
||||
const settings = { memoryBackendType: "qmd" };
|
||||
const instructions = buildTriageMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain("## Project Memory");
|
||||
// QMD should NOT unconditionally reference .fusion/memory.md
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
// QMD should NOT unconditionally reference .fusion/memory/MEMORY.md
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toContain("memory_search");
|
||||
expect(instructions).toContain("memory_get");
|
||||
});
|
||||
@@ -515,32 +524,32 @@ describe("project-memory", () => {
|
||||
const settings = { memoryBackendType: "qmd" };
|
||||
const instructions = buildTriageMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain("## Project Memory");
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toContain("memory_search");
|
||||
});
|
||||
|
||||
it("does not include .fusion/memory.md for non-file backends without instructionPathHint", () => {
|
||||
it("does not include .fusion/memory/MEMORY.md for non-file backends without instructionPathHint", () => {
|
||||
const settings = { memoryBackendType: "some-custom-backend" };
|
||||
const instructions = buildTriageMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain("memory_search");
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
|
||||
it("defaults to qmd guidance when settings are omitted", () => {
|
||||
const instructions = buildTriageMemoryInstructions(testDir);
|
||||
expect(instructions).toContain("memory_search");
|
||||
expect(instructions).toContain("memory_get");
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backend-aware buildExecutionMemoryInstructions ─────────────────────────────────
|
||||
|
||||
describe("buildExecutionMemoryInstructions with backend settings", () => {
|
||||
it("includes .fusion/memory.md for file backend", () => {
|
||||
it("includes .fusion/memory/MEMORY.md for file backend", () => {
|
||||
const settings = { memoryBackendType: "file" };
|
||||
const instructions = buildExecutionMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain(".fusion/memory.md");
|
||||
expect(instructions).toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toContain("## Project Memory");
|
||||
// Should have write instructions
|
||||
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
|
||||
@@ -553,17 +562,17 @@ describe("project-memory", () => {
|
||||
// Should NOT contain write/update directives
|
||||
expect(instructions).not.toMatch(/write.*memory|update.*memory/i);
|
||||
// Should NOT contain the specific file path
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
// Should instruct to consult memory at start
|
||||
expect(instructions).toMatch(/consult.*memory/i);
|
||||
});
|
||||
|
||||
it("does not include .fusion/memory.md for qmd backend", () => {
|
||||
it("does not include .fusion/memory/MEMORY.md for qmd backend", () => {
|
||||
const settings = { memoryBackendType: "qmd" };
|
||||
const instructions = buildExecutionMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain("## Project Memory");
|
||||
// QMD should NOT unconditionally reference .fusion/memory.md
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
// QMD should NOT unconditionally reference .fusion/memory/MEMORY.md
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toContain("memory_search");
|
||||
expect(instructions).toContain("memory_get");
|
||||
});
|
||||
@@ -572,7 +581,7 @@ describe("project-memory", () => {
|
||||
const settings = { memoryBackendType: "qmd" };
|
||||
const instructions = buildExecutionMemoryInstructions(testDir, settings);
|
||||
expect(instructions).toContain("## Project Memory");
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toContain("memory_search");
|
||||
// Contains "end of execution" write guidance
|
||||
expect(instructions).toMatch(/end of execution/i);
|
||||
@@ -586,7 +595,7 @@ describe("project-memory", () => {
|
||||
const instructions = buildExecutionMemoryInstructions(testDir);
|
||||
expect(instructions).toContain("memory_search");
|
||||
expect(instructions).toContain("memory_get");
|
||||
expect(instructions).not.toContain(".fusion/memory.md");
|
||||
expect(instructions).not.toContain(".fusion/memory/MEMORY.md");
|
||||
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Project Memory Bootstrap
|
||||
*
|
||||
* Provides the canonical path and default scaffold for `.fusion/memory.md`,
|
||||
* Provides the canonical path and default scaffold for `.fusion/memory/MEMORY.md`,
|
||||
* plus idempotent `ensure` functions that create memory only when missing.
|
||||
*
|
||||
* This module supports both file-based (direct filesystem) and backend-aware
|
||||
@@ -19,7 +19,7 @@
|
||||
* - The memory instruction templates used by triage and executor prompts
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir, stat } from "node:fs/promises";
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
// ── Constants ────────────────────────────────────────────────────────
|
||||
|
||||
/** Path to the project memory file relative to project root. */
|
||||
export const MEMORY_FILE_PATH = ".fusion/memory.md";
|
||||
export const MEMORY_FILE_PATH = ".fusion/memory/MEMORY.md";
|
||||
|
||||
/** Canonical absolute path helper. */
|
||||
export function memoryFilePath(rootDir: string): string {
|
||||
@@ -88,19 +88,15 @@ export function getDefaultMemoryScaffold(): string {
|
||||
*/
|
||||
export async function ensureMemoryFile(rootDir: string): Promise<boolean> {
|
||||
const filePath = memoryFilePath(rootDir);
|
||||
if (existsSync(filePath)) {
|
||||
await ensureOpenClawMemoryFiles(rootDir);
|
||||
return false;
|
||||
const legacyPath = join(rootDir, ".fusion", "memory.md");
|
||||
const hasLegacySeed = existsSync(legacyPath);
|
||||
|
||||
const { longTermCreated } = await ensureOpenClawMemoryFiles(rootDir);
|
||||
if (longTermCreated && !hasLegacySeed) {
|
||||
await writeFile(filePath, getDefaultMemoryScaffold(), "utf-8");
|
||||
}
|
||||
|
||||
const dir = join(rootDir, ".fusion");
|
||||
if (!existsSync(dir)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
await writeFile(filePath, getDefaultMemoryScaffold(), "utf-8");
|
||||
await ensureOpenClawMemoryFiles(rootDir);
|
||||
return true;
|
||||
return longTermCreated;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,7 +136,7 @@ export interface MemoryInstructionContext {
|
||||
capabilities: import("./memory-backend.js").MemoryBackendCapabilities;
|
||||
/**
|
||||
* Path hint for memory instructions.
|
||||
* - For "file" backend: ".fusion/memory.md"
|
||||
* - For "file" backend: ".fusion/memory/MEMORY.md"
|
||||
* - For "readonly" backend: null (no write path)
|
||||
* - For "qmd"/non-file backends: null (path is backend-specific)
|
||||
*/
|
||||
@@ -152,9 +148,9 @@ export interface MemoryInstructionContext {
|
||||
*
|
||||
* This function determines what memory instructions should be injected
|
||||
* based on the configured backend type:
|
||||
* - "file" backend: full read/write instructions with `.fusion/memory.md` path
|
||||
* - "file" backend: full read/write instructions with `.fusion/memory/MEMORY.md` path
|
||||
* - "readonly" backend: read-only instructions, no write/update directives
|
||||
* - "qmd"/non-file backends: instructions without unconditional `.fusion/memory.md` path
|
||||
* - "qmd"/non-file backends: instructions without unconditional `.fusion/memory/MEMORY.md` path
|
||||
* (unless `instructionPathHint` is explicitly non-null)
|
||||
*
|
||||
* @param settings - Optional project settings containing memoryEnabled and memoryBackendType
|
||||
@@ -213,7 +209,7 @@ export function resolveMemoryInstructionContext(
|
||||
case "file":
|
||||
return {
|
||||
backendType: "file",
|
||||
backendName: "File (.fusion/memory.md)",
|
||||
backendName: "File (.fusion/memory/MEMORY.md)",
|
||||
capabilities: {
|
||||
readable: true,
|
||||
writable: true,
|
||||
@@ -221,7 +217,7 @@ export function resolveMemoryInstructionContext(
|
||||
hasConflictResolution: false,
|
||||
persistent: true,
|
||||
},
|
||||
instructionPathHint: ".fusion/memory.md",
|
||||
instructionPathHint: ".fusion/memory/MEMORY.md",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
@@ -396,9 +392,9 @@ export async function getProjectMemory(
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @param settings - Optional project settings for backend-aware instruction generation.
|
||||
* When provided, the function branches based on memoryBackendType:
|
||||
* - "file": includes `.fusion/memory.md` read guidance
|
||||
* - "file": includes `.fusion/memory/MEMORY.md` read guidance
|
||||
* - "readonly": read-only instructions, no write directives
|
||||
* - "qmd"/non-file: instructions without unconditional `.fusion/memory.md` path
|
||||
* - "qmd"/non-file: instructions without unconditional `.fusion/memory/MEMORY.md` path
|
||||
* @returns The memory instruction section string, or empty string if the
|
||||
* memory file does not exist yet.
|
||||
*/
|
||||
@@ -436,14 +432,13 @@ This project has a memory system 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. 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
|
||||
|
||||
Do not read all memory or read \`.fusion/memory.md\` directly by default. If memory is irrelevant, skip it.
|
||||
Do not read all memory directly by default. If memory is irrelevant, skip it.
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -476,9 +471,9 @@ This project has a memory system that stores durable project learnings.
|
||||
* @param rootDir - Absolute path to the project root directory.
|
||||
* @param settings - Optional project settings for backend-aware instruction generation.
|
||||
* When provided, the function branches based on memoryBackendType:
|
||||
* - "file": includes `.fusion/memory.md` read/write guidance
|
||||
* - "file": includes `.fusion/memory/MEMORY.md` read/write guidance
|
||||
* - "readonly": read-only instructions, no write/update directives
|
||||
* - "qmd"/non-file: instructions without unconditional `.fusion/memory.md` path
|
||||
* - "qmd"/non-file: instructions without unconditional `.fusion/memory/MEMORY.md` path
|
||||
* @returns The memory instruction section string.
|
||||
*/
|
||||
export function buildExecutionMemoryInstructions(
|
||||
@@ -515,13 +510,12 @@ This project has a memory system 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 observations and open loops
|
||||
- Legacy fallback: \`.fusion/memory.md\`
|
||||
|
||||
**At the start of execution:**
|
||||
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.
|
||||
4. Do not load all memory 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
|
||||
@@ -607,16 +601,8 @@ This project has a memory system that stores durable project learnings.
|
||||
*/
|
||||
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)) {
|
||||
if (!existsSync(longTermPath)) {
|
||||
return "";
|
||||
}
|
||||
return readFile(filePath, "utf-8");
|
||||
return readFile(longTermPath, "utf-8");
|
||||
}
|
||||
|
||||
@@ -8251,10 +8251,8 @@ Task with acceptance criteria
|
||||
});
|
||||
|
||||
describe("project memory bootstrap", () => {
|
||||
it("creates .fusion/memory.md on init when memoryEnabled is default (true)", async () => {
|
||||
// The default store in beforeEach already called init() with default settings
|
||||
// memoryEnabled defaults to true, so memory.md should exist
|
||||
const memoryPath = join(rootDir, ".fusion", "memory.md");
|
||||
it("creates .fusion/memory/MEMORY.md on init when memoryEnabled is default (true)", async () => {
|
||||
const memoryPath = join(rootDir, ".fusion", "memory", "MEMORY.md");
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
|
||||
const content = await readFile(memoryPath, "utf-8");
|
||||
@@ -8263,71 +8261,60 @@ Task with acceptance criteria
|
||||
expect(content).toContain("## Conventions");
|
||||
});
|
||||
|
||||
it("does not create .fusion/memory.md when memoryEnabled is false", async () => {
|
||||
it("does not create .fusion/memory/MEMORY.md when memoryEnabled is false after re-init", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
let localStore: TaskStore | undefined;
|
||||
let secondStore: TaskStore | undefined;
|
||||
try {
|
||||
const localStore = new TaskStore(localRoot, localGlobal);
|
||||
localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
// Explicitly disable memory
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
// Delete the file if it was created during init (default enabled)
|
||||
const memoryPath = join(localRoot, ".fusion", "memory.md");
|
||||
if (existsSync(memoryPath)) {
|
||||
await unlink(memoryPath);
|
||||
}
|
||||
localStore.close();
|
||||
|
||||
// Re-init with memory disabled
|
||||
const store2 = new TaskStore(localRoot, localGlobal);
|
||||
// Manually set memoryEnabled to false before init
|
||||
await store2.init();
|
||||
await store2.updateSettings({ memoryEnabled: false } as any);
|
||||
// After setting false, verify we can init without creating
|
||||
store2.close();
|
||||
|
||||
// Create a third store with memory disabled in config
|
||||
const store3 = new TaskStore(localRoot, localGlobal);
|
||||
await store3.updateSettings({ memoryEnabled: false } as any);
|
||||
await store3.init();
|
||||
|
||||
// Memory file should not exist if it was deleted
|
||||
// But init creates it by default, then we disabled it
|
||||
// The key behavior is that when memoryEnabled is explicitly false,
|
||||
// init() should not create the file
|
||||
store3.close();
|
||||
} finally {
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates .fusion/memory.md when memory is toggled on via updateSettings", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
try {
|
||||
const localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
|
||||
// First disable memory
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
const memoryPath = join(localRoot, ".fusion", "memory.md");
|
||||
|
||||
// Delete the file that was created during init
|
||||
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
|
||||
if (existsSync(memoryPath)) {
|
||||
await unlink(memoryPath);
|
||||
}
|
||||
expect(existsSync(memoryPath)).toBe(false);
|
||||
|
||||
localStore.close();
|
||||
localStore = undefined;
|
||||
|
||||
secondStore = new TaskStore(localRoot, localGlobal);
|
||||
await secondStore.init();
|
||||
|
||||
expect(existsSync(memoryPath)).toBe(false);
|
||||
} finally {
|
||||
secondStore?.close();
|
||||
localStore?.close();
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates .fusion/memory/MEMORY.md when memory is toggled on via updateSettings", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
let localStore: TaskStore | undefined;
|
||||
try {
|
||||
localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
|
||||
|
||||
if (existsSync(memoryPath)) {
|
||||
await unlink(memoryPath);
|
||||
}
|
||||
expect(existsSync(memoryPath)).toBe(false);
|
||||
|
||||
// Now toggle memory back on
|
||||
await localStore.updateSettings({ memoryEnabled: true } as any);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
|
||||
const content = await readFile(memoryPath, "utf-8");
|
||||
expect(content).toContain("# Project Memory");
|
||||
|
||||
localStore.close();
|
||||
} finally {
|
||||
localStore?.close();
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
@@ -8336,25 +8323,22 @@ Task with acceptance criteria
|
||||
it("does not overwrite existing memory content when toggled on", async () => {
|
||||
const localRoot = makeTmpDir();
|
||||
const localGlobal = makeTmpDir();
|
||||
let localStore: TaskStore | undefined;
|
||||
try {
|
||||
const localStore = new TaskStore(localRoot, localGlobal);
|
||||
localStore = new TaskStore(localRoot, localGlobal);
|
||||
await localStore.init();
|
||||
const memoryPath = join(localRoot, ".fusion", "memory.md");
|
||||
const memoryPath = join(localRoot, ".fusion", "memory", "MEMORY.md");
|
||||
|
||||
// Write custom content
|
||||
const customContent = "# My Custom Memory\n\nImportant stuff";
|
||||
await writeFile(memoryPath, customContent, "utf-8");
|
||||
|
||||
// Disable then re-enable memory
|
||||
await localStore.updateSettings({ memoryEnabled: false } as any);
|
||||
await localStore.updateSettings({ memoryEnabled: true } as any);
|
||||
|
||||
// Custom content should be preserved
|
||||
const content = await readFile(memoryPath, "utf-8");
|
||||
expect(content).toBe(customContent);
|
||||
|
||||
localStore.close();
|
||||
} finally {
|
||||
localStore?.close();
|
||||
await rm(localRoot, { recursive: true, force: true });
|
||||
await rm(localGlobal, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1269,9 +1269,9 @@ export interface ProjectSettings {
|
||||
* Extraction only runs if BOTH this time has elapsed AND memory has grown
|
||||
* by more than MIN_INSIGHT_GROWTH_CHARS characters. Default: 86400000 (24h). */
|
||||
insightExtractionMinIntervalMs?: number;
|
||||
/** When enabled, agents will consult and update the .fusion/memory/ directory with durable
|
||||
/** When enabled, agents will consult and update .fusion/memory/MEMORY.md with durable
|
||||
* project learnings. When disabled, agents will not include memory instructions
|
||||
* in their prompts and will not read or write to the .fusion/memory/ directory.
|
||||
* in their prompts and will not read or write to .fusion/memory/MEMORY.md.
|
||||
* Default: true (enabled for backward compatibility). */
|
||||
memoryEnabled?: boolean;
|
||||
/** Memory backend type for pluggable memory storage.
|
||||
|
||||
Reference in New Issue
Block a user