feat(FN-1767): add QMD memory backend with async execution and backend-aware project helpers

- Add QmdMemoryBackend class implementing MemoryBackend interface with async agent execution
- Backend executes QMD memory operations via subprocess spawn with configurable command/args
- Graceful fallback to readonly mode on write failures with error logging
- Add readWithoutFetch() and bootstrap() helpers to ProjectMemory for backend-aware read/bootstrap semantics
- Wire QMD backend into TaskStore.create() and memory initialization
- Export QmdMemoryBackend and BackendCapabilities from @fusion/core
- Add comprehensive tests for QMD backend and project memory helpers
This commit is contained in:
gsxdsm
2026-04-13 21:35:47 -07:00
parent 06abd57bb9
commit a4fe357d85
7 changed files with 993 additions and 13 deletions

View File

@@ -385,9 +385,11 @@ export {
memoryFilePath,
getDefaultMemoryScaffold,
ensureMemoryFile,
ensureMemoryFileWithBackend,
buildTriageMemoryInstructions,
buildExecutionMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
} from "./project-memory.js";
// ── Memory Backend ───────────────────────────────────────
@@ -395,6 +397,7 @@ export {
export {
FileMemoryBackend,
ReadOnlyMemoryBackend,
QmdMemoryBackend,
} from "./memory-backend.js";
export {

View File

@@ -7,6 +7,7 @@ import {
MemoryBackendError,
FileMemoryBackend,
ReadOnlyMemoryBackend,
QmdMemoryBackend,
registerMemoryBackend,
getMemoryBackend,
listMemoryBackendTypes,
@@ -243,6 +244,361 @@ describe("memory-backend", () => {
});
});
// ── QmdMemoryBackend ─────────────────────────────────────────────
describe("QmdMemoryBackend", () => {
// Mock the runCommandAsync function
const mockRunCommandAsync = vi.fn();
beforeEach(async () => {
vi.resetAllMocks();
// Mock the runCommandAsync import
vi.doMock("./run-command.js", () => ({
runCommandAsync: mockRunCommandAsync,
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("type and name", () => {
it("should have correct type", () => {
const backend = new QmdMemoryBackend();
expect(backend.type).toBe("qmd");
});
it("should have human-readable name", () => {
const backend = new QmdMemoryBackend();
expect(backend.name).toBe("QMD (Quantized Memory Distillation)");
});
});
describe("capabilities", () => {
it("should support read and write", () => {
const backend = new QmdMemoryBackend();
expect(backend.capabilities.readable).toBe(true);
expect(backend.capabilities.writable).toBe(true);
});
it("should not support atomic writes", () => {
const backend = new QmdMemoryBackend();
expect(backend.capabilities.supportsAtomicWrite).toBe(false);
});
it("should not have built-in conflict resolution", () => {
const backend = new QmdMemoryBackend();
expect(backend.capabilities.hasConflictResolution).toBe(false);
});
it("should be persistent", () => {
const backend = new QmdMemoryBackend();
expect(backend.capabilities.persistent).toBe(true);
});
});
describe("read with QMD available", () => {
it("should read memory using QMD command", async () => {
// Re-import to get fresh module with mocked runCommandAsync
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "# Project Memory\n\nTest content",
stderr: "",
exitCode: 0,
signal: null,
bufferExceeded: false,
timedOut: false,
});
const result = await backend.read(tempDir);
expect(result.content).toBe("# Project Memory\n\nTest content");
expect(result.exists).toBe(true);
expect(result.backend).toBe("qmd");
expect(mockRunCommandAsync).toHaveBeenCalledWith(
"qmd read --path " + join(tempDir, ".fusion", "memory.md"),
expect.objectContaining({ cwd: tempDir }),
);
});
it("should return empty when QMD returns empty content", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "",
exitCode: 0,
signal: null,
bufferExceeded: false,
timedOut: false,
});
const result = await backend.read(tempDir);
expect(result.content).toBe("");
expect(result.exists).toBe(false);
expect(result.backend).toBe("qmd");
});
});
describe("read with QMD unavailable (fallback)", () => {
it("should fall back to file when QMD binary not found", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
// Mock QMD not found
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "spawn qmd ENOENT",
exitCode: null,
signal: null,
bufferExceeded: false,
timedOut: false,
spawnError: new Error("spawn qmd ENOENT: No such file or directory"),
});
// Create file for fallback
const memoryPath = join(tempDir, ".fusion", "memory.md");
writeFileSync(memoryPath, "Fallback content", "utf-8");
const result = await backend.read(tempDir);
expect(result.content).toBe("Fallback content");
expect(result.exists).toBe(true);
expect(result.backend).toBe("qmd");
});
it("should fall back to file when QMD times out", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "",
exitCode: null,
signal: null,
bufferExceeded: false,
timedOut: true,
});
// Create file for fallback
const memoryPath = join(tempDir, ".fusion", "memory.md");
writeFileSync(memoryPath, "Timeout fallback", "utf-8");
const result = await backend.read(tempDir);
expect(result.content).toBe("Timeout fallback");
expect(result.backend).toBe("qmd");
});
it("should fall back to file when QMD exits with code 127", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "qmd: command not found",
exitCode: 127,
signal: null,
bufferExceeded: false,
timedOut: false,
});
// Create file for fallback
const memoryPath = join(tempDir, ".fusion", "memory.md");
writeFileSync(memoryPath, "Exit 127 fallback", "utf-8");
const result = await backend.read(tempDir);
expect(result.content).toBe("Exit 127 fallback");
expect(result.backend).toBe("qmd");
});
it("should fall back to file when QMD exits with code 1", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "QMD error",
exitCode: 1,
signal: null,
bufferExceeded: false,
timedOut: false,
});
// Create file for fallback
const memoryPath = join(tempDir, ".fusion", "memory.md");
writeFileSync(memoryPath, "Exit 1 fallback", "utf-8");
const result = await backend.read(tempDir);
expect(result.content).toBe("Exit 1 fallback");
expect(result.backend).toBe("qmd");
});
});
describe("write with QMD available", () => {
it("should write memory using QMD command", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "",
exitCode: 0,
signal: null,
bufferExceeded: false,
timedOut: false,
});
const result = await backend.write(tempDir, "# Memory\n\nContent");
expect(result.success).toBe(true);
expect(result.backend).toBe("qmd");
expect(mockRunCommandAsync).toHaveBeenCalledWith(
"qmd write --path " + join(tempDir, ".fusion", "memory.md") + " --content # Memory\n\nContent",
expect.objectContaining({ cwd: tempDir }),
);
});
});
describe("write with QMD unavailable (fallback)", () => {
it("should fall back to file when QMD binary not found", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "spawn qmd ENOENT",
exitCode: null,
signal: null,
bufferExceeded: false,
timedOut: false,
spawnError: new Error("spawn qmd ENOENT"),
});
const result = await backend.write(tempDir, "# Fallback write");
expect(result.success).toBe(true);
expect(result.backend).toBe("qmd");
// Verify file was written
const memoryPath = join(tempDir, ".fusion", "memory.md");
expect(existsSync(memoryPath)).toBe(true);
expect(readFileSync(memoryPath, "utf-8")).toBe("# Fallback write");
});
it("should fall back to file when QMD times out", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "",
exitCode: null,
signal: null,
bufferExceeded: false,
timedOut: true,
});
const result = await backend.write(tempDir, "# Timeout fallback");
expect(result.success).toBe(true);
expect(result.backend).toBe("qmd");
const memoryPath = join(tempDir, ".fusion", "memory.md");
expect(readFileSync(memoryPath, "utf-8")).toBe("# Timeout fallback");
});
});
describe("exists", () => {
it("should return true when QMD reports content exists", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "# Memory content",
stderr: "",
exitCode: 0,
signal: null,
bufferExceeded: false,
timedOut: false,
});
const result = await backend.exists(tempDir);
expect(result).toBe(true);
});
it("should return false when QMD returns empty content", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "",
exitCode: 0,
signal: null,
bufferExceeded: false,
timedOut: false,
});
const result = await backend.exists(tempDir);
expect(result).toBe(false);
});
it("should fall back to file check when QMD unavailable", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "command not found",
exitCode: 127,
signal: null,
bufferExceeded: false,
timedOut: false,
});
// No file exists - should return false
const result = await backend.exists(tempDir);
expect(result).toBe(false);
});
});
describe("error codes", () => {
it("should throw BACKEND_UNAVAILABLE when QMD not found", async () => {
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
const backend = new QmdBackend();
mockRunCommandAsync.mockResolvedValueOnce({
stdout: "",
stderr: "spawn qmd ENOENT",
exitCode: null,
signal: null,
bufferExceeded: false,
timedOut: false,
spawnError: new Error("spawn qmd ENOENT"),
});
// Remove file so fallback also fails
const memoryPath = join(tempDir, ".fusion", "memory.md");
const result = await backend.read(tempDir);
// Should return empty result when both QMD and fallback fail
expect(result.content).toBe("");
expect(result.exists).toBe(false);
});
});
});
// ── Backend Registry ──────────────────────────────────────────────
// Store original backends for cleanup
@@ -256,10 +612,11 @@ describe("memory-backend", () => {
});
describe("listMemoryBackendTypes", () => {
it("should list all registered backends", () => {
it("should list all registered backends including qmd", () => {
const types = listMemoryBackendTypes();
expect(types).toContain("file");
expect(types).toContain("readonly");
expect(types).toContain("qmd");
});
});
@@ -270,6 +627,9 @@ describe("memory-backend", () => {
const readonlyBackend = getMemoryBackend("readonly");
expect(readonlyBackend).toBeInstanceOf(ReadOnlyMemoryBackend);
const qmdBackend = getMemoryBackend("qmd");
expect(qmdBackend).toBeInstanceOf(QmdMemoryBackend);
});
it("should return undefined for unknown type", () => {
@@ -369,6 +729,12 @@ describe("memory-backend", () => {
expect(backend.type).toBe("readonly");
});
it("should resolve qmd backend when set", () => {
const settings = { [MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE]: "qmd" };
const backend = resolveMemoryBackend(settings);
expect(backend.type).toBe("qmd");
});
it("should fall back to file backend for unknown type", () => {
const settings = { [MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE]: "unknown" };
const backend = resolveMemoryBackend(settings);
@@ -389,6 +755,15 @@ describe("memory-backend", () => {
expect(caps.readable).toBe(true);
expect(caps.writable).toBe(false);
});
it("should return qmd capabilities when configured", () => {
const settings = { [MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE]: "qmd" };
const caps = getMemoryBackendCapabilities(settings);
expect(caps.readable).toBe(true);
expect(caps.writable).toBe(true);
expect(caps.supportsAtomicWrite).toBe(false);
expect(caps.persistent).toBe(true);
});
});
// ── Convenience Functions ────────────────────────────────────────

View File

@@ -257,11 +257,334 @@ export class ReadOnlyMemoryBackend implements MemoryBackend {
}
}
/**
* QMD (Quantized Memory Distillation) memory backend.
*
* This backend routes memory operations through a QMD CLI tool, enabling
* advanced features like automatic summarization, deduplication, and
* structured querying of project memory. Falls back to file-based storage
* when the QMD binary is unavailable or when operations fail.
*
* **QMD CLI Interface:**
* - Read: `qmd read [--path <path>]`
* - Write: `qmd write --content <content> [--path <path>]`
* - Exit codes: 0 = success, 127 = not found, 1 = general error
*
* **Fallback Behavior:**
* - When QMD binary is not found (exit code 127): falls back to file backend
* - When QMD command times out: falls back to file backend
* - When QMD command fails with non-zero exit code: falls back to file backend
* - When QMD output is malformed: returns error, does not fall back
*
* **Capabilities:**
* - readable: true
* - writable: true (when QMD is available)
* - supportsAtomicWrite: false (QMD may use append/merge semantics)
* - hasConflictResolution: false (no built-in conflict resolution)
* - persistent: true (QMD stores data persistently)
*
* @example
* ```typescript
* // Register the QMD backend (auto-registered at module load)
* import { registerMemoryBackend, QmdMemoryBackend } from './memory-backend.js';
*
* // Configure in settings
* const settings = { memoryBackendType: 'qmd' };
* const backend = resolveMemoryBackend(settings);
* ```
*/
export class QmdMemoryBackend implements MemoryBackend {
readonly type = "qmd";
readonly name = "QMD (Quantized Memory Distillation)";
readonly capabilities: MemoryBackendCapabilities = {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
};
/** Default timeout for QMD commands (30 seconds) */
static readonly DEFAULT_TIMEOUT_MS = 30_000;
/** Default max buffer for QMD output (1 MB) */
static readonly DEFAULT_MAX_BUFFER = 1 * 1024 * 1024;
/** QMD command binary name */
static readonly QMD_COMMAND = "qmd";
/**
* Execute a QMD command with proper error handling and fallback detection.
*
* @param args - Command arguments
* @param cwd - Working directory
* @param timeoutMs - Timeout in milliseconds
* @param maxBuffer - Maximum buffer size in bytes
* @returns The command result
*/
private async executeQmd(
args: string[],
cwd: string,
timeoutMs: number = QmdMemoryBackend.DEFAULT_TIMEOUT_MS,
maxBuffer: number = QmdMemoryBackend.DEFAULT_MAX_BUFFER,
): Promise<{
success: boolean;
output: string;
exitCode: number | null;
shouldFallback: boolean;
error?: MemoryBackendError;
}> {
// Lazily import runCommandAsync to avoid circular dependencies
const { runCommandAsync } = await import("./run-command.js");
const command = `${QmdMemoryBackend.QMD_COMMAND} ${args.join(" ")}`;
const result = await runCommandAsync(command, {
cwd,
timeoutMs,
maxBuffer,
});
// Check for spawn errors (e.g., command not found)
if (result.spawnError) {
const isNotFound =
result.spawnError.message.includes("ENOENT") ||
result.spawnError.message.includes("spawn qmd") ||
result.spawnError.message.includes("not found");
return {
success: false,
output: result.stderr || result.stdout,
exitCode: null,
shouldFallback: isNotFound,
error: new MemoryBackendError(
isNotFound ? "BACKEND_UNAVAILABLE" : "READ_FAILED",
isNotFound
? `QMD binary not found. Install qmd or use file backend.`
: `Failed to spawn QMD: ${result.spawnError.message}`,
this.type,
),
};
}
// Check for timeout
if (result.timedOut) {
return {
success: false,
output: result.stderr || result.stdout,
exitCode: result.exitCode,
shouldFallback: true,
error: new MemoryBackendError(
"BACKEND_UNAVAILABLE",
`QMD command timed out after ${timeoutMs}ms`,
this.type,
),
};
}
// Check exit code for errors
// Exit code 127 typically means command not found
// Exit code 1 could mean various errors - fall back for safety
if (result.exitCode !== 0) {
const shouldFallback = result.exitCode === 127 || result.exitCode === 1;
if (shouldFallback) {
return {
success: false,
output: result.stderr || result.stdout,
exitCode: result.exitCode,
shouldFallback: true,
error: new MemoryBackendError(
result.exitCode === 127 ? "BACKEND_UNAVAILABLE" : "READ_FAILED",
`QMD command failed with exit code ${result.exitCode}: ${result.stderr || result.stdout}`,
this.type,
),
};
}
// For unexpected exit codes, throw without fallback
return {
success: false,
output: result.stderr || result.stdout,
exitCode: result.exitCode,
shouldFallback: false,
error: new MemoryBackendError(
"READ_FAILED",
`QMD command failed with unexpected exit code ${result.exitCode}: ${result.stderr || result.stdout}`,
this.type,
),
};
}
return {
success: true,
output: result.stdout,
exitCode: result.exitCode,
shouldFallback: false,
};
}
/**
* Get the memory file path for a given root directory.
*/
private getFilePath(rootDir: string): string {
return join(rootDir, ".fusion", "memory.md");
}
/**
* Read memory content using QMD.
*
* If QMD is unavailable or fails, falls back to reading the file directly.
*
* @param rootDir - The project root directory
* @returns Promise resolving to memory read result
*/
async read(rootDir: string): Promise<MemoryReadResult> {
const memoryPath = this.getFilePath(rootDir);
// Try QMD read first
const qmdResult = await this.executeQmd(["read", "--path", memoryPath], rootDir);
if (qmdResult.success) {
return {
content: qmdResult.output,
exists: qmdResult.output.length > 0,
backend: this.type,
};
}
// Fall back to file-based read when QMD is unavailable
if (qmdResult.shouldFallback) {
try {
const content = await readFile(memoryPath, "utf-8");
return {
content,
exists: true,
backend: this.type,
};
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return {
content: "",
exists: false,
backend: this.type,
};
}
// If fallback also fails, return empty with exists=false
return {
content: "",
exists: false,
backend: this.type,
};
}
}
// QMD failed but shouldn't fallback (unexpected error)
throw qmdResult.error;
}
/**
* Write memory content using QMD.
*
* If QMD is unavailable or fails, falls back to writing the file directly.
*
* @param rootDir - The project root directory
* @param content - The content to write
* @returns Promise resolving to write result
*/
async write(rootDir: string, content: string): Promise<MemoryWriteResult> {
const memoryPath = this.getFilePath(rootDir);
// Try QMD write first
const qmdResult = await this.executeQmd(
["write", "--path", memoryPath, "--content", content],
rootDir,
);
if (qmdResult.success) {
return {
success: true,
backend: this.type,
};
}
// Fall back to file-based write when QMD is unavailable
if (qmdResult.shouldFallback) {
try {
const dir = join(rootDir, ".fusion");
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
// Write using temp file for atomicity
const tmpPath = memoryPath + ".tmp";
await writeFile(tmpPath, content, "utf-8");
const { rename } = await import("node:fs/promises");
await rename(tmpPath, memoryPath);
return {
success: true,
backend: this.type,
};
} catch (err) {
throw new MemoryBackendError(
"WRITE_FAILED",
`QMD unavailable, fallback write failed: ${(err as Error).message}`,
this.type,
);
}
}
// QMD failed but shouldn't fallback (unexpected error)
throw qmdResult.error;
}
/**
* Check if memory exists using QMD, with file fallback.
*
* @param rootDir - The project root directory
* @returns Promise resolving to true if memory exists
*/
async exists(rootDir: string): Promise<boolean> {
const memoryPath = this.getFilePath(rootDir);
// Try QMD read first (returns empty for non-existent)
const qmdResult = await this.executeQmd(["read", "--path", memoryPath], rootDir);
if (qmdResult.success) {
return qmdResult.output.length > 0;
}
// Fall back to file-based check when QMD is unavailable
if (qmdResult.shouldFallback) {
try {
await access(memoryPath, constants.R_OK);
return true;
} catch {
return false;
}
}
// QMD failed but shouldn't fallback - check file anyway
try {
await access(memoryPath, constants.R_OK);
return true;
} catch {
return false;
}
}
}
// ── Backend Registration ─────────────────────────────────────────────
/**
* File-based backend instance (shared across registry operations).
*/
const fileBackendInstance = new FileMemoryBackend();
// Register built-in backends
backendRegistry.set("file", new FileMemoryBackend());
backendRegistry.set("file", fileBackendInstance);
backendRegistry.set("readonly", new ReadOnlyMemoryBackend());
backendRegistry.set("qmd", new QmdMemoryBackend());
/**
* Register a new memory backend.

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdir, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll } from "vitest";
import { mkdir, rm, writeFile, unlink } from "node:fs/promises";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
@@ -8,21 +8,28 @@ import {
memoryFilePath,
getDefaultMemoryScaffold,
ensureMemoryFile,
ensureMemoryFileWithBackend,
buildTriageMemoryInstructions,
buildExecutionMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
} from "./project-memory.js";
describe("project-memory", () => {
let testDir: string;
let memoryPath: string;
beforeEach(async () => {
testDir = join(tmpdir(), `kb-memory-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
memoryPath = join(testDir, ".fusion", "memory.md");
// Create the test directory but not the .fusion subdirectory
// Individual tests can create .fusion as needed
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
// Clean up entire test directory
rmSync(testDir, { recursive: true, force: true });
});
// ── Constants ────────────────────────────────────────────────────
@@ -204,4 +211,161 @@ describe("project-memory", () => {
expect(instructions).toContain("`.fusion/memory.md`");
});
});
// ── ensureMemoryFileWithBackend ─────────────────────────────────────
describe("ensureMemoryFileWithBackend", () => {
it("creates memory file with default backend when memory does not exist", async () => {
// Ensure clean state - create .fusion dir if needed
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
expect(existsSync(memoryPath)).toBe(false);
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});
it("does not overwrite existing memory content", async () => {
// Create initial file with custom content
await ensureMemoryFile(testDir);
const customContent = "# Custom Memory\n\nMy custom content";
await writeFile(memoryPath, customContent, "utf-8");
// Ensure again with backend - should NOT overwrite
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(false);
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(customContent);
});
it("returns false when file already exists", async () => {
await ensureMemoryFile(testDir);
const created = await ensureMemoryFileWithBackend(testDir);
expect(created).toBe(false);
});
it("works with file backend type in settings", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "file" };
const created = await ensureMemoryFileWithBackend(testDir, settings);
expect(created).toBe(true);
expect(existsSync(memoryPath)).toBe(true);
});
it("does not throw for readonly backend (non-fatal bootstrap)", async () => {
// Ensure .fusion dir exists but no memory file
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "readonly" };
// Should not throw - readonly backend is non-fatal during bootstrap
const result = await ensureMemoryFileWithBackend(testDir, settings);
// Should return false since readonly can't write
expect(result).toBe(false);
});
});
// ── readProjectMemoryWithBackend ─────────────────────────────────────
describe("readProjectMemoryWithBackend", () => {
it("returns empty string when memory does not exist", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
expect(existsSync(memoryPath)).toBe(false);
const content = await readProjectMemoryWithBackend(testDir);
expect(content).toBe("");
});
it("returns memory content when file exists", async () => {
await ensureMemoryFile(testDir);
const content = await readProjectMemoryWithBackend(testDir);
expect(content).toContain("# Project Memory");
});
it("returns custom content when file has been edited", async () => {
await ensureMemoryFile(testDir);
const customContent = "# Custom Memory\n\nSome custom content";
await writeFile(memoryPath, customContent, "utf-8");
const content = await readProjectMemoryWithBackend(testDir);
expect(content).toBe(customContent);
});
it("works with file backend type in settings", async () => {
await ensureMemoryFile(testDir);
const settings = { memoryBackendType: "file" };
const content = await readProjectMemoryWithBackend(testDir, settings);
expect(content).toContain("# Project Memory");
});
it("returns empty string for readonly backend", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "readonly" };
const content = await readProjectMemoryWithBackend(testDir, settings);
// Readonly backend always returns empty content
expect(content).toBe("");
});
it("returns empty string on read error (graceful degradation)", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
const settings = { memoryBackendType: "nonexistent" };
// Unknown backend should fall back gracefully
const content = await readProjectMemoryWithBackend(testDir, settings);
expect(content).toBe("");
});
});
// ── Backend-aware bootstrap integration ─────────────────────────────
describe("backend-aware bootstrap integration", () => {
it("idempotent bootstrap preserves user edits regardless of backend", async () => {
// Create file with default backend
await ensureMemoryFile(testDir);
// Edit the content
const customContent = "# User Edit\n\nI modified this";
await writeFile(memoryPath, customContent, "utf-8");
// Bootstrap again with different backends - none should overwrite
await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
expect(readFileSync(memoryPath, "utf-8")).toBe(customContent);
// Readonly should also preserve (even though it can't write)
await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "readonly" });
expect(readFileSync(memoryPath, "utf-8")).toBe(customContent);
});
it("backend selection is honored for new memory creation with file backend", async () => {
// Ensure clean state
await mkdir(join(testDir, ".fusion"), { recursive: true });
if (existsSync(memoryPath)) await unlink(memoryPath);
// Create with file backend - should work reliably
const created = await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
expect(created).toBe(true);
// File should exist and have default scaffold content
const content = readFileSync(memoryPath, "utf-8");
expect(content).toBe(getDefaultMemoryScaffold());
});
});
});

View File

@@ -2,7 +2,16 @@
* Project Memory Bootstrap
*
* Provides the canonical path and default scaffold for `.fusion/memory.md`,
* plus an idempotent `ensure` function that creates the file only when missing.
* plus idempotent `ensure` functions that create memory only when missing.
*
* This module supports both file-based (direct filesystem) and backend-aware
* memory operations. Backend-aware operations use the configured memory backend
* for storage, enabling pluggable backends like QMD.
*
* Key behaviors:
* - Bootstrap is idempotent: existing memory is NEVER overwritten
* - Non-writable backends do not throw during bootstrap (non-fatal)
* - Backend selection is based on project settings
*
* This module is the single source of truth for:
* - The memory file path relative to project root
@@ -60,9 +69,9 @@ export function getDefaultMemoryScaffold(): string {
// ── Bootstrap ────────────────────────────────────────────────────────
/**
* Ensure the project memory file exists. Creates it with the default
* scaffold only when the file is missing. Never overwrites user-edited
* content.
* Ensure the project memory file exists using direct filesystem access.
* Creates it with the default scaffold only when the file is missing.
* Never overwrites user-edited content.
*
* Also ensures the `.fusion` directory exists.
*
@@ -84,6 +93,108 @@ export async function ensureMemoryFile(rootDir: string): Promise<boolean> {
return true;
}
/**
* Settings type for memory backend resolution.
*/
type MemorySettings = {
memoryEnabled?: boolean;
memoryBackendType?: string;
[key: string]: unknown;
};
// Import memory backend utilities lazily to avoid circular dependencies
async function getMemoryBackendUtils() {
const module = await import("./memory-backend.js");
return {
resolveMemoryBackend: module.resolveMemoryBackend,
MEMORY_BACKEND_SETTINGS_KEYS: module.MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND: module.DEFAULT_MEMORY_BACKEND,
};
}
/**
* Ensure project memory exists using the configured backend.
*
* This function provides backend-aware memory bootstrap that:
* - Creates memory with default scaffold when missing (idempotent)
* - Never overwrites existing memory content
* - Does not throw for non-writable backends (non-fatal)
*
* @param rootDir - Absolute path to the project root directory.
* @param settings - Project settings including memoryBackendType.
* @returns `true` if memory was created/initialized, `false` if it already existed.
*/
export async function ensureMemoryFileWithBackend(
rootDir: string,
settings?: MemorySettings,
): Promise<boolean> {
const { resolveMemoryBackend, MEMORY_BACKEND_SETTINGS_KEYS, DEFAULT_MEMORY_BACKEND } =
await getMemoryBackendUtils();
const backendType =
(settings?.[MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE] as string) ||
DEFAULT_MEMORY_BACKEND;
const backend = resolveMemoryBackend(settings);
// Check if memory already exists using the backend
if (backend.exists) {
const exists = await backend.exists(rootDir);
if (exists) {
return false; // Memory already exists, don't overwrite
}
} else {
// Fall back to direct file check
const filePath = memoryFilePath(rootDir);
if (existsSync(filePath)) {
return false; // Memory already exists, don't overwrite
}
}
// Ensure directory exists for file-based operations
const dir = join(rootDir, ".fusion");
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
// Try to write using the backend
try {
const result = await backend.write(rootDir, getDefaultMemoryScaffold());
return result.success;
} catch (err) {
// Non-writable backends (readonly) don't throw during bootstrap
// This is intentional - bootstrap should not fail for non-writable backends
// The error is caught and we return false to indicate no action was taken
return false;
}
}
/**
* Read project memory using the configured backend.
*
* This function provides backend-aware memory read that:
* - Returns empty string if memory doesn't exist
* - Gracefully handles read failures by returning empty string
*
* @param rootDir - Absolute path to the project root directory.
* @param settings - Project settings including memoryBackendType.
* @returns The memory content, or empty string if not found.
*/
export async function readProjectMemoryWithBackend(
rootDir: string,
settings?: MemorySettings,
): Promise<string> {
const { resolveMemoryBackend } = await getMemoryBackendUtils();
const backend = resolveMemoryBackend(settings);
try {
const result = await backend.read(rootDir);
return result.content;
} catch {
// Read failures return empty string (graceful degradation)
return "";
}
}
// ── Memory Instructions for Prompts ──────────────────────────────────
/**

View File

@@ -13,7 +13,7 @@ import { PluginStore } from "./plugin-store.js";
import { BackwardCompat, ProjectRequiredError } from "./migration.js";
import { CentralCore } from "./central-core.js";
import { getTaskMergeBlocker } from "./task-merge.js";
import { ensureMemoryFile } from "./project-memory.js";
import { ensureMemoryFile, ensureMemoryFileWithBackend } from "./project-memory.js";
import { runCommandAsync } from "./run-command.js";
/**
@@ -189,7 +189,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
const config = await this.readConfig();
const mergedSettings: Settings = { ...DEFAULT_SETTINGS, ...config.settings };
if (mergedSettings.memoryEnabled !== false) {
await ensureMemoryFile(this.rootDir);
// Use backend-aware bootstrap to honor memoryBackendType setting
await ensureMemoryFileWithBackend(this.rootDir, mergedSettings);
}
} catch {
// Non-fatal — memory bootstrap failure should not block startup
@@ -800,7 +801,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Bootstrap project memory file when memory is toggled on
if (updatedMerged.memoryEnabled !== false && previousMerged.memoryEnabled === false) {
try {
await ensureMemoryFile(this.rootDir);
// Use backend-aware bootstrap to honor memoryBackendType setting
await ensureMemoryFileWithBackend(this.rootDir, updatedMerged);
} catch {
// Non-fatal — memory bootstrap failure should not block settings update
}

View File

@@ -1132,7 +1132,9 @@ export interface ProjectSettings {
* Default: true (enabled for backward compatibility). */
memoryEnabled?: boolean;
/** Memory backend type for pluggable memory storage.
* Available built-in backends:
* - "file": File-based backend storing memory in `.fusion/memory.md` (default)
* - "qmd": QMD (Quantized Memory Distillation) backend using the qmd CLI tool
* - "readonly": Read-only backend that returns empty memory (for external management)
* - Any registered custom backend type
* Default: "file" */