feat(FN-1738): merge fusion/fn-1738
This commit is contained in:
@@ -247,21 +247,6 @@ describe("memory-backend", () => {
|
|||||||
// ── QmdMemoryBackend ─────────────────────────────────────────────
|
// ── QmdMemoryBackend ─────────────────────────────────────────────
|
||||||
|
|
||||||
describe("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", () => {
|
describe("type and name", () => {
|
||||||
it("should have correct type", () => {
|
it("should have correct type", () => {
|
||||||
const backend = new QmdMemoryBackend();
|
const backend = new QmdMemoryBackend();
|
||||||
@@ -270,7 +255,7 @@ describe("memory-backend", () => {
|
|||||||
|
|
||||||
it("should have human-readable name", () => {
|
it("should have human-readable name", () => {
|
||||||
const backend = new QmdMemoryBackend();
|
const backend = new QmdMemoryBackend();
|
||||||
expect(backend.name).toBe("QMD (Quantized Memory Distillation)");
|
expect(backend.name).toBe("QMD (qmd index/query integration)");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -297,304 +282,113 @@ describe("memory-backend", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("read with QMD available", () => {
|
describe("read", () => {
|
||||||
it("should read memory using QMD command", async () => {
|
it("should read memory from filesystem and return qmd backend identifier", async () => {
|
||||||
// Re-import to get fresh module with mocked runCommandAsync
|
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||||
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
|
writeFileSync(memoryPath, "# Project Memory\n\nTest content", "utf-8");
|
||||||
const backend = new QmdBackend();
|
|
||||||
|
|
||||||
mockRunCommandAsync.mockResolvedValueOnce({
|
|
||||||
stdout: "# Project Memory\n\nTest content",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
bufferExceeded: false,
|
|
||||||
timedOut: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const backend = new QmdMemoryBackend();
|
||||||
const result = await backend.read(tempDir);
|
const result = await backend.read(tempDir);
|
||||||
|
|
||||||
expect(result.content).toBe("# Project Memory\n\nTest content");
|
expect(result.content).toBe("# Project Memory\n\nTest content");
|
||||||
expect(result.exists).toBe(true);
|
expect(result.exists).toBe(true);
|
||||||
expect(result.backend).toBe("qmd");
|
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 () => {
|
it("should return empty content when file does not exist", async () => {
|
||||||
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
|
const backend = new QmdMemoryBackend();
|
||||||
const backend = new QmdBackend();
|
|
||||||
|
|
||||||
mockRunCommandAsync.mockResolvedValueOnce({
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
bufferExceeded: false,
|
|
||||||
timedOut: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await backend.read(tempDir);
|
const result = await backend.read(tempDir);
|
||||||
|
|
||||||
expect(result.content).toBe("");
|
expect(result.content).toBe("");
|
||||||
expect(result.exists).toBe(false);
|
expect(result.exists).toBe(false);
|
||||||
expect(result.backend).toBe("qmd");
|
expect(result.backend).toBe("qmd");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("read with QMD unavailable (fallback)", () => {
|
it("should return empty content for empty file", async () => {
|
||||||
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");
|
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||||
writeFileSync(memoryPath, "Fallback content", "utf-8");
|
writeFileSync(memoryPath, "", "utf-8");
|
||||||
|
|
||||||
|
const backend = new QmdMemoryBackend();
|
||||||
const result = await backend.read(tempDir);
|
const result = await backend.read(tempDir);
|
||||||
|
|
||||||
expect(result.content).toBe("Fallback content");
|
expect(result.content).toBe("");
|
||||||
expect(result.exists).toBe(true);
|
expect(result.exists).toBe(true); // File exists, just empty
|
||||||
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");
|
expect(result.backend).toBe("qmd");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("write with QMD available", () => {
|
describe("write", () => {
|
||||||
it("should write memory using QMD command", async () => {
|
it("should write memory to filesystem and return qmd backend identifier", async () => {
|
||||||
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
|
const backend = new QmdMemoryBackend();
|
||||||
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");
|
const result = await backend.write(tempDir, "# Memory\n\nContent");
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.backend).toBe("qmd");
|
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)", () => {
|
// Verify file was actually written
|
||||||
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");
|
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||||
expect(existsSync(memoryPath)).toBe(true);
|
expect(existsSync(memoryPath)).toBe(true);
|
||||||
expect(readFileSync(memoryPath, "utf-8")).toBe("# Fallback write");
|
expect(readFileSync(memoryPath, "utf-8")).toBe("# Memory\n\nContent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to file when QMD times out", async () => {
|
it("should overwrite existing content", 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");
|
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||||
expect(readFileSync(memoryPath, "utf-8")).toBe("# Timeout fallback");
|
writeFileSync(memoryPath, "Original content", "utf-8");
|
||||||
|
|
||||||
|
const backend = new QmdMemoryBackend();
|
||||||
|
await backend.write(tempDir, "Updated content");
|
||||||
|
|
||||||
|
expect(readFileSync(memoryPath, "utf-8")).toBe("Updated content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create .fusion directory if missing", async () => {
|
||||||
|
const newDir = join(tempDir, "new-project");
|
||||||
|
await mkdir(newDir, { recursive: true });
|
||||||
|
|
||||||
|
const backend = new QmdMemoryBackend();
|
||||||
|
await backend.write(newDir, "# Memory");
|
||||||
|
|
||||||
|
const memoryPath = join(newDir, ".fusion", "memory.md");
|
||||||
|
expect(existsSync(memoryPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle unicode content", async () => {
|
||||||
|
const backend = new QmdMemoryBackend();
|
||||||
|
const unicodeContent = "# プロジェクトメモリ\n\n日本語のテスト 🎉";
|
||||||
|
|
||||||
|
await backend.write(tempDir, unicodeContent);
|
||||||
|
|
||||||
|
const result = await backend.read(tempDir);
|
||||||
|
expect(result.content).toBe(unicodeContent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("exists", () => {
|
describe("exists", () => {
|
||||||
it("should return true when QMD reports content exists", async () => {
|
it("should return true when memory file exists", async () => {
|
||||||
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
|
const memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||||
const backend = new QmdBackend();
|
writeFileSync(memoryPath, "Content", "utf-8");
|
||||||
|
|
||||||
mockRunCommandAsync.mockResolvedValueOnce({
|
|
||||||
stdout: "# Memory content",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
bufferExceeded: false,
|
|
||||||
timedOut: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const backend = new QmdMemoryBackend();
|
||||||
const result = await backend.exists(tempDir);
|
const result = await backend.exists(tempDir);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return false when QMD returns empty content", async () => {
|
it("should return false when memory file does not exist", async () => {
|
||||||
const { QmdMemoryBackend: QmdBackend } = await import("./memory-backend.js");
|
const backend = new QmdMemoryBackend();
|
||||||
const backend = new QmdBackend();
|
|
||||||
|
|
||||||
mockRunCommandAsync.mockResolvedValueOnce({
|
|
||||||
stdout: "",
|
|
||||||
stderr: "",
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
bufferExceeded: false,
|
|
||||||
timedOut: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await backend.exists(tempDir);
|
const result = await backend.exists(tempDir);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to file check when QMD unavailable", async () => {
|
it("should return true for empty file", 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 memoryPath = join(tempDir, ".fusion", "memory.md");
|
||||||
|
writeFileSync(memoryPath, "", "utf-8");
|
||||||
|
|
||||||
const result = await backend.read(tempDir);
|
const backend = new QmdMemoryBackend();
|
||||||
|
const result = await backend.exists(tempDir);
|
||||||
|
|
||||||
// Should return empty result when both QMD and fallback fail
|
expect(result).toBe(true);
|
||||||
expect(result.content).toBe("");
|
|
||||||
expect(result.exists).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -258,44 +258,30 @@ export class ReadOnlyMemoryBackend implements MemoryBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* QMD (Quantized Memory Distillation) memory backend.
|
* QMD (qmd index/query integration) memory backend.
|
||||||
*
|
*
|
||||||
* This backend routes memory operations through a QMD CLI tool, enabling
|
* Stores project memory in `.fusion/memory.md` so it can be indexed and queried
|
||||||
* advanced features like automatic summarization, deduplication, and
|
* by the external `qmd` tool. Read/write operations use direct filesystem access
|
||||||
* structured querying of project memory. Falls back to file-based storage
|
* for reliability. The `qmd` tool can be configured separately to watch and index
|
||||||
* when the QMD binary is unavailable or when operations fail.
|
* the memory file for advanced querying capabilities.
|
||||||
*
|
|
||||||
* **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:**
|
* **Capabilities:**
|
||||||
* - readable: true
|
* - readable: true
|
||||||
* - writable: true (when QMD is available)
|
* - writable: true
|
||||||
* - supportsAtomicWrite: false (QMD may use append/merge semantics)
|
* - supportsAtomicWrite: false (QMD indexing is async/external)
|
||||||
* - hasConflictResolution: false (no built-in conflict resolution)
|
* - hasConflictResolution: false
|
||||||
* - persistent: true (QMD stores data persistently)
|
* - persistent: true (memory file persists in `.fusion/memory.md`)
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // Register the QMD backend (auto-registered at module load)
|
* // Configure in settings to enable qmd integration
|
||||||
* import { registerMemoryBackend, QmdMemoryBackend } from './memory-backend.js';
|
|
||||||
*
|
|
||||||
* // Configure in settings
|
|
||||||
* const settings = { memoryBackendType: 'qmd' };
|
* const settings = { memoryBackendType: 'qmd' };
|
||||||
* const backend = resolveMemoryBackend(settings);
|
* const backend = resolveMemoryBackend(settings);
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class QmdMemoryBackend implements MemoryBackend {
|
export class QmdMemoryBackend implements MemoryBackend {
|
||||||
readonly type = "qmd";
|
readonly type = "qmd";
|
||||||
readonly name = "QMD (Quantized Memory Distillation)";
|
readonly name = "QMD (qmd index/query integration)";
|
||||||
readonly capabilities: MemoryBackendCapabilities = {
|
readonly capabilities: MemoryBackendCapabilities = {
|
||||||
readable: true,
|
readable: true,
|
||||||
writable: true,
|
writable: true,
|
||||||
@@ -304,273 +290,48 @@ export class QmdMemoryBackend implements MemoryBackend {
|
|||||||
persistent: true,
|
persistent: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default timeout for QMD commands (30 seconds) */
|
/** Delegate file backend for actual I/O operations */
|
||||||
static readonly DEFAULT_TIMEOUT_MS = 30_000;
|
private readonly fileBackend = new FileMemoryBackend();
|
||||||
|
|
||||||
/** 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.
|
* Read memory content from the filesystem.
|
||||||
*
|
|
||||||
* @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
|
* @param rootDir - The project root directory
|
||||||
* @returns Promise resolving to memory read result
|
* @returns Promise resolving to memory read result
|
||||||
*/
|
*/
|
||||||
async read(rootDir: string): Promise<MemoryReadResult> {
|
async read(rootDir: string): Promise<MemoryReadResult> {
|
||||||
const memoryPath = this.getFilePath(rootDir);
|
// Delegate to file backend, but return "qmd" as the backend identifier
|
||||||
|
const result = await this.fileBackend.read(rootDir);
|
||||||
// Try QMD read first
|
return {
|
||||||
const qmdResult = await this.executeQmd(["read", "--path", memoryPath], rootDir);
|
...result,
|
||||||
|
backend: this.type,
|
||||||
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.
|
* Write memory content to the filesystem.
|
||||||
*
|
|
||||||
* If QMD is unavailable or fails, falls back to writing the file directly.
|
|
||||||
*
|
*
|
||||||
* @param rootDir - The project root directory
|
* @param rootDir - The project root directory
|
||||||
* @param content - The content to write
|
* @param content - The content to write
|
||||||
* @returns Promise resolving to write result
|
* @returns Promise resolving to write result
|
||||||
*/
|
*/
|
||||||
async write(rootDir: string, content: string): Promise<MemoryWriteResult> {
|
async write(rootDir: string, content: string): Promise<MemoryWriteResult> {
|
||||||
const memoryPath = this.getFilePath(rootDir);
|
// Delegate to file backend, but return "qmd" as the backend identifier
|
||||||
|
const result = await this.fileBackend.write(rootDir, content);
|
||||||
// Try QMD write first
|
return {
|
||||||
const qmdResult = await this.executeQmd(
|
...result,
|
||||||
["write", "--path", memoryPath, "--content", content],
|
backend: this.type,
|
||||||
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.
|
* Check if memory file exists.
|
||||||
*
|
*
|
||||||
* @param rootDir - The project root directory
|
* @param rootDir - The project root directory
|
||||||
* @returns Promise resolving to true if memory exists
|
* @returns Promise resolving to true if memory exists
|
||||||
*/
|
*/
|
||||||
async exists(rootDir: string): Promise<boolean> {
|
async exists(rootDir: string): Promise<boolean> {
|
||||||
const memoryPath = this.getFilePath(rootDir);
|
return this.fileBackend.exists(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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user