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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user