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:
@@ -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 {
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ──────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" */
|
||||
|
||||
Reference in New Issue
Block a user