refactor(FN-2126): align memory handling with canonical .fusion/memory paths

- Update memory docs and contracts to reference canonical .fusion/memory files while treating the legacy top-level memory file as compatibility-only
- Tighten memory backend path normalization messaging and map stale qmd legacy top-level memory results back to .fusion/memory/MEMORY.md
- Remove legacy memory read/write fallback branches from backend initialization paths and rely on ensureOpenClawMemoryFiles() migration behavior
- Preserve migration-seeded legacy content during ensureMemoryFile() bootstrap and add regression coverage for seeded long-term memory creation
This commit is contained in:
Fusion
2026-04-19 05:04:30 -07:00
committed by gsxdsm
parent 3472c122d4
commit a956ce5a4f
7 changed files with 41 additions and 40 deletions

View File

@@ -487,7 +487,7 @@ SQLite schema is initialized in `packages/core/src/db.ts` and uses:
- `.fusion/memory/MEMORY.md`
- `.fusion/memory/YYYY-MM-DD.md`
- `.fusion/memory/DREAMS.md`
- Legacy `.fusion/memory.md` is a deprecated legacy fallback (migration seed/alias path) and is not canonical storage.
- The legacy top-level memory file is migration-compatibility only (seed/alias behavior) and is not canonical storage.
### File-based side stores
Some data remains intentionally filesystem-based:

View File

@@ -109,7 +109,7 @@ When enabled, Fusion uses OpenClaw-style memory files:
- `.fusion/memory/MEMORY.md` — long-term project memory
- `.fusion/memory/YYYY-MM-DD.md` — daily running notes
- `.fusion/memory/DREAMS.md` — dream-processing memory file
- Legacy `.fusion/memory.md` is a deprecated legacy fallback (migration seed/alias) and should not be treated as canonical
- The legacy top-level memory file is a deprecated migration fallback (seed/alias behavior) and should not be treated as canonical
Use project memory for reusable patterns, constraints, and pitfalls that should persist across tasks.

View File

@@ -38,7 +38,7 @@ Fusion currently has two related but distinct memory systems:
| `MEMORY_WORKSPACE_PATH` | `.fusion/memory` | `memory-backend.ts` |
| `MEMORY_LONG_TERM_FILENAME` | `MEMORY.md` | `memory-backend.ts` |
| `MEMORY_DREAMS_FILENAME` | `DREAMS.md` | `memory-backend.ts` |
| `LEGACY_MEMORY_FILE_PATH` | `.fusion/memory.md` | `memory-backend.ts` |
| `LEGACY_MEMORY_FILE_PATH` | legacy top-level memory file | `memory-backend.ts` |
| `DEFAULT_MEMORY_BACKEND` | `qmd` | `memory-backend.ts` |
| `MEMORY_FILE_PATH` | `.fusion/memory/MEMORY.md` | `project-memory.ts` |
| `MEMORY_WORKING_PATH` | `.fusion/memory/MEMORY.md` | `memory-insights.ts` |
@@ -93,7 +93,7 @@ Runtime backend resolution uses an internal `MemorySettings` shape with `memoryB
### 1.5 Key Invariants
1. Canonical layered long-term memory file is **`.fusion/memory/MEMORY.md`**.
2. Legacy **`.fusion/memory.md`** is compatibility-only (migration seed/legacy alias), not canonical storage.
2. The legacy top-level memory file is compatibility-only (migration seed/legacy alias), not canonical storage.
3. Default backend is **`qmd`** (`DEFAULT_MEMORY_BACKEND`).
4. Backend selection key is **`memoryBackendType`**.
5. Prompt instruction context is backend-aware (`file` path hint vs `qmd`/`readonly` behavior).
@@ -121,7 +121,7 @@ Fusion adopted OpenClaw-style layered memory while keeping a concrete TypeScript
| Pluggable backends | Implemented (`file`, `qmd`, `readonly`, custom) | Contract must document `MemoryBackend` exactly as shipped |
| Capability negotiation | Implemented via boolean-struct `capabilities` | No enum or lifecycle-based capability API |
| Search | Implemented through optional `search(rootDir, options)` hooks | Results are bounded snippets, not full-document dumps |
| Migration compatibility | Implemented via `ensureOpenClawMemoryFiles()` and legacy constants | Legacy `.fusion/memory.md` remains compatibility-only |
| Migration compatibility | Implemented via `ensureOpenClawMemoryFiles()` and legacy constants | Legacy top-level memory file remains compatibility-only |
---
@@ -242,7 +242,7 @@ Allowed workspace files are constrained to:
- Canonical layered workspace: `.fusion/memory/`
- Canonical long-term file: `.fusion/memory/MEMORY.md`
- Legacy `.fusion/memory.md`: compatibility-only for migration/legacy alias handling
- Legacy top-level memory file: compatibility-only for migration/legacy alias handling
#### 3.8.2 Legacy compatibility scope
@@ -297,7 +297,7 @@ It is related to, but not equivalent to, backend selection and prompt instructio
### 4.2 Must-Not-Break Invariants
1. Canonical long-term layered memory remains `.fusion/memory/MEMORY.md`.
2. Legacy `.fusion/memory.md` remains compatibility-only and must not become canonical again.
2. Legacy top-level memory file remains compatibility-only and must not become canonical again.
3. Runtime backend selection remains keyed by `memoryBackendType`.
4. `DEFAULT_MEMORY_BACKEND` remains `"qmd"` unless explicitly changed in code + docs.
5. Prompt instruction behavior remains backend-dependent via `resolveMemoryInstructionContext()`.
@@ -315,7 +315,7 @@ It is related to, but not equivalent to, backend selection and prompt instructio
|---|---|
| Settings persistence | Unknown `memoryBackendType` may be persisted, but runtime still falls back safely |
| Read-only backend | Writes fail with typed `READ_ONLY` error |
| Legacy upgrades | Existing `.fusion/memory.md` can seed canonical `.fusion/memory/MEMORY.md` once during migration |
| Legacy upgrades | Existing legacy top-level memory file can seed canonical `.fusion/memory/MEMORY.md` once during migration |
| Memory file APIs | Must enforce workspace-relative path validation and project root boundaries |
| Prompt generation | Must honor `memoryEnabled` toggle and backend-aware instruction context |

View File

@@ -412,7 +412,7 @@ Fusion can automatically extract insights from project memory and prune transien
| File | Description |
|------|-------------|
| `.fusion/memory/MEMORY.md` | Long-term memory (updated when pruning is applied and validated) |
| `.fusion/memory.md` | Deprecated legacy fallback (migration compatibility only; not canonical storage) |
| Legacy top-level memory file | Deprecated migration fallback (compatibility only; not canonical storage) |
| `.fusion/memory-insights.md` | Long-term insights distilled from working memory |
| `.fusion/memory-audit.md` | Human-readable audit report after each extraction |

View File

@@ -654,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`,
`Memory path '${rawPath}' is outside allowed files: .fusion/memory/MEMORY.md, .fusion/memory/DREAMS.md, .fusion/memory/YYYY-MM-DD.md`,
"memory",
);
}
@@ -789,12 +789,14 @@ function normalizeQmdSearchResultPath(rootDir: string, rawPath: unknown): string
}
const lowerCandidate = candidate.toLowerCase();
const legacyLower = LEGACY_MEMORY_FILE_PATH.toLowerCase();
if (lowerCandidate === legacyLower || lowerCandidate.endsWith(`/${legacyLower}`)) {
return LEGACY_MEMORY_FILE_PATH;
}
const normalizedBaseName = basename(candidate).toLowerCase();
const normalizedDirName = dirname(lowerCandidate).replace(/\\/g, "/");
// Map legacy top-level memory paths from stale qmd indexes to the canonical
// layered long-term path without exposing legacy paths to callers.
if (normalizedBaseName === "memory.md" && (normalizedDirName === ".fusion" || normalizedDirName.endsWith("/.fusion"))) {
return `${MEMORY_WORKSPACE_PATH}/${MEMORY_LONG_TERM_FILENAME}`;
}
if (normalizedBaseName === MEMORY_LONG_TERM_FILENAME.toLowerCase()) {
return `${MEMORY_WORKSPACE_PATH}/${MEMORY_LONG_TERM_FILENAME}`;
}

View File

@@ -103,6 +103,19 @@ describe("project-memory", () => {
expect(content).toBe(getDefaultMemoryScaffold());
});
it("preserves migration-seeded legacy content when long-term memory is created", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
const legacyContent = "# Legacy Memory\n\nPreserve me";
await writeFile(join(testDir, LEGACY_MEMORY_FILE_PATH), legacyContent, "utf-8");
const created = await ensureMemoryFile(testDir);
expect(created).toBe(true);
expect(existsSync(memoryFilePath(testDir))).toBe(true);
const content = await readProjectMemory(testDir);
expect(content).toBe(legacyContent);
});
it("creates the .fusion directory if missing", async () => {
expect(existsSync(join(testDir, ".fusion"))).toBe(false);
await ensureMemoryFile(testDir);

View File

@@ -24,6 +24,7 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import {
ensureOpenClawMemoryFiles,
getDefaultLongTermMemoryScaffold,
memoryLongTermPath,
type MemorySearchOptions,
type MemorySearchResult,
@@ -88,12 +89,15 @@ export function getDefaultMemoryScaffold(): string {
*/
export async function ensureMemoryFile(rootDir: string): Promise<boolean> {
const filePath = memoryFilePath(rootDir);
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");
// Ensure direct bootstrap uses the historical scaffold expected by this module.
// If migration seeded from an older legacy file, preserve that seeded content.
if (longTermCreated) {
const createdContent = await readFile(filePath, "utf-8");
if (createdContent === getDefaultLongTermMemoryScaffold()) {
await writeFile(filePath, getDefaultMemoryScaffold(), "utf-8");
}
}
return longTermCreated;
@@ -272,15 +276,6 @@ 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);
}
}
}
refreshQmdIfNeeded();
return false; // Memory already exists, don't overwrite
}
@@ -288,15 +283,6 @@ export async function ensureMemoryFileWithBackend(
// 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);
}
}
}
refreshQmdIfNeeded();
return false; // Memory already exists, don't overwrite
}
@@ -309,8 +295,8 @@ export async function ensureMemoryFileWithBackend(
}
// 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`.
// backends. `ensureOpenClawMemoryFiles()` handles one-way migration seeding
// from the legacy top-level memory file when upgrading older projects.
if (backend.capabilities.writable) {
await ensureOpenClawMemoryFiles(rootDir);
}