fix(FN-000): simplify qmd memory settings

This commit is contained in:
gsxdsm
2026-04-17 08:16:36 -07:00
parent 1433ca98ce
commit 75dc5a8c77
20 changed files with 576 additions and 511 deletions

View File

@@ -462,6 +462,8 @@ export {
memoryExists,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
QMD_INSTALL_COMMAND,
isQmdAvailable,
} from "./memory-backend.js";
export { MemoryBackendError } from "./memory-backend.js";

View File

@@ -499,16 +499,16 @@ describe("memory-backend", () => {
});
it("should export default backend type", () => {
expect(DEFAULT_MEMORY_BACKEND).toBe("file");
expect(DEFAULT_MEMORY_BACKEND).toBe("qmd");
});
});
// ── Resolution Functions ──────────────────────────────────────────
describe("resolveMemoryBackend", () => {
it("should resolve file backend by default", () => {
it("should resolve qmd backend by default", () => {
const backend = resolveMemoryBackend();
expect(backend.type).toBe("file");
expect(backend.type).toBe("qmd");
});
it("should resolve file backend when explicitly set", () => {
@@ -529,18 +529,19 @@ describe("memory-backend", () => {
expect(backend.type).toBe("qmd");
});
it("should fall back to file backend for unknown type", () => {
it("should fall back to qmd backend for unknown type", () => {
const settings = { [MEMORY_BACKEND_SETTINGS_KEYS.MEMORY_BACKEND_TYPE]: "unknown" };
const backend = resolveMemoryBackend(settings);
expect(backend.type).toBe("file");
expect(backend.type).toBe("qmd");
});
});
describe("getMemoryBackendCapabilities", () => {
it("should return file backend capabilities by default", () => {
it("should return qmd backend capabilities by default", () => {
const caps = getMemoryBackendCapabilities();
expect(caps.readable).toBe(true);
expect(caps.writable).toBe(true);
expect(caps.supportsAtomicWrite).toBe(false);
});
it("should return readonly capabilities when configured", () => {
@@ -563,14 +564,14 @@ describe("memory-backend", () => {
// ── Convenience Functions ────────────────────────────────────────
describe("readMemory", () => {
it("should read using file backend by default", async () => {
it("should read using qmd backend by default", async () => {
const memoryPath = join(tempDir, ".fusion", "memory.md");
writeFileSync(memoryPath, "Test memory content", "utf-8");
const result = await readMemory(tempDir);
expect(result.content).toBe("Test memory content");
expect(result.exists).toBe(true);
expect(result.backend).toBe("file");
expect(result.backend).toBe("qmd");
});
it("should return empty content when file does not exist", async () => {
@@ -588,11 +589,11 @@ describe("memory-backend", () => {
});
describe("writeMemory", () => {
it("should write using file backend by default", async () => {
it("should write using qmd backend by default", async () => {
const result = await writeMemory(tempDir, "# Memory\n\nContent");
expect(result.success).toBe(true);
expect(result.backend).toBe("file");
expect(result.backend).toBe("qmd");
const memoryPath = join(tempDir, ".fusion", "memory.md");
expect(readFileSync(memoryPath, "utf-8")).toBe("# Memory\n\nContent");

View File

@@ -5,8 +5,8 @@
* Different backends can be plugged in based on project settings, with
* each backend declaring its capabilities (readable, writable, etc.).
*
* The default backend is the file-based backend that stores memory in
* `.fusion/memory.md`.
* The default backend is qmd-backed search over layered memory files, with
* local file search as a fallback when qmd is not installed.
*/
import { readFile, writeFile, mkdir, access, constants, readdir, stat } from "node:fs/promises";
@@ -17,6 +17,7 @@ export const MEMORY_WORKSPACE_PATH = ".fusion/memory";
export const MEMORY_LONG_TERM_FILENAME = "MEMORY.md";
export const MEMORY_DREAMS_FILENAME = "DREAMS.md";
export const LEGACY_MEMORY_FILE_PATH = ".fusion/memory.md";
export const QMD_INSTALL_COMMAND = "bun add -g qmd";
const DAILY_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
const MAX_MEMORY_SNIPPET_CHARS = 700;
@@ -193,7 +194,7 @@ const backendRegistry = new Map<string, MemoryBackend>();
* File-based memory backend.
*
* Stores project memory in `.fusion/memory.md` at the project root.
* This is the default backend that preserves existing UX.
* Preserves the legacy `.fusion/memory.md` storage path when explicitly selected.
*/
export class FileMemoryBackend implements MemoryBackend {
readonly type = "file";
@@ -442,7 +443,7 @@ export class QmdMemoryBackend implements MemoryBackend {
if (qmdResults.length > 0) {
return qmdResults.map((result) => ({ ...result, backend: this.type }));
}
return searchMemoryFiles(rootDir, options, "file");
return searchMemoryFiles(rootDir, options, this.type);
}
}
@@ -762,6 +763,21 @@ async function searchWithQmd(rootDir: string, options: MemorySearchOptions): Pro
}
}
export async function isQmdAvailable(): Promise<boolean> {
try {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
await execFileAsync("qmd", ["--help"], {
timeout: 3000,
maxBuffer: 128 * 1024,
});
return true;
} catch {
return false;
}
}
// ── Backend Registration ─────────────────────────────────────────────
/**
@@ -812,7 +828,7 @@ export const MEMORY_BACKEND_SETTINGS_KEYS = {
/**
* Default memory backend type.
*/
export const DEFAULT_MEMORY_BACKEND = "file";
export const DEFAULT_MEMORY_BACKEND = "qmd";
// ── Type for Settings ───────────────────────────────────────────────

View File

@@ -13,6 +13,7 @@ import {
buildExecutionMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
searchProjectMemory,
resolveMemoryInstructionContext,
} from "./project-memory.js";
@@ -146,14 +147,15 @@ describe("project-memory", () => {
expect(instructions.length).toBeGreaterThan(0);
});
it("contains the memory file path", () => {
it("does not inject a raw memory file path by default", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).toContain(".fusion/memory.md");
expect(instructions).not.toContain(".fusion/memory.md");
});
it("instructs agent to read the memory file", () => {
it("instructs agent to search memory first", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).toMatch(/read.*memory\.md/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("instructs agent to incorporate learnings", () => {
@@ -170,15 +172,16 @@ describe("project-memory", () => {
expect(instructions.length).toBeGreaterThan(0);
});
it("contains the memory file path", () => {
it("does not inject a raw memory file path by default", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).toContain(".fusion/memory.md");
expect(instructions).not.toContain(".fusion/memory.md");
});
it("instructs agent to read memory at start", () => {
it("instructs agent to search memory at start", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).toMatch(/start of execution/i);
expect(instructions).toMatch(/read.*memory\.md/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("instructs agent to selectively write learnings at end", () => {
@@ -206,10 +209,9 @@ describe("project-memory", () => {
expect(instructions).toMatch(/consolidate|update.*refine.*existing|edit.*existing/i);
});
it("specifies project-root path not worktree-local", () => {
it("keeps qmd default path-agnostic", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
// Should use .fusion/memory.md (project root relative) not absolute worktree paths
expect(instructions).toContain("`.fusion/memory.md`");
expect(instructions).not.toContain("`.fusion/memory.md`");
});
});
@@ -420,13 +422,13 @@ describe("project-memory", () => {
// ── resolveMemoryInstructionContext ─────────────────────────────────────
describe("resolveMemoryInstructionContext", () => {
it("returns file backend context by default", () => {
it("returns qmd backend context by default", () => {
const ctx = resolveMemoryInstructionContext();
expect(ctx.backendType).toBe("file");
expect(ctx.backendName).toBe("File (.fusion/memory.md)");
expect(ctx.backendType).toBe("qmd");
expect(ctx.backendName).toBe("QMD (Quantized Memory Distillation)");
expect(ctx.capabilities.readable).toBe(true);
expect(ctx.capabilities.writable).toBe(true);
expect(ctx.instructionPathHint).toBe(".fusion/memory.md");
expect(ctx.instructionPathHint).toBeNull();
});
it("returns file backend context when explicitly set", () => {
@@ -453,10 +455,10 @@ describe("project-memory", () => {
expect(ctx.instructionPathHint).toBeNull();
});
it("returns file backend for unknown backend type", () => {
it("returns qmd backend for unknown backend type", () => {
const ctx = resolveMemoryInstructionContext({ memoryBackendType: "unknown" });
expect(ctx.backendType).toBe("file"); // Falls back to file
expect(ctx.instructionPathHint).toBe(".fusion/memory.md");
expect(ctx.backendType).toBe("qmd");
expect(ctx.instructionPathHint).toBeNull();
});
});
@@ -488,8 +490,8 @@ describe("project-memory", () => {
expect(instructions).toContain("## Project Memory");
// QMD should NOT unconditionally reference .fusion/memory.md
expect(instructions).not.toContain(".fusion/memory.md");
// Should instruct to consult project memory
expect(instructions).toMatch(/consult.*project memory/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("QMD triage instructions completeness - contains consult guidance", () => {
@@ -497,22 +499,21 @@ describe("project-memory", () => {
const instructions = buildTriageMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
expect(instructions).not.toContain(".fusion/memory.md");
// Should contain consult guidance
expect(instructions).toMatch(/consult.*memory/i);
expect(instructions).toContain("memory_search");
});
it("does not include .fusion/memory.md for non-file backends without instructionPathHint", () => {
const settings = { memoryBackendType: "some-custom-backend" };
const instructions = buildTriageMemoryInstructions(testDir, settings);
// Non-file backends fall back to file behavior but with generic path
// Actually unknown backends fall back to file, so this test validates the fallback
// Let's test with explicit settings that have no path hint
expect(instructions).toContain("memory_search");
expect(instructions).not.toContain(".fusion/memory.md");
});
it("maintains backward compatibility when settings omitted (file behavior)", () => {
it("defaults to qmd guidance when settings are omitted", () => {
const instructions = buildTriageMemoryInstructions(testDir);
expect(instructions).toContain(".fusion/memory.md");
expect(instructions).toMatch(/read.*memory\.md/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
expect(instructions).not.toContain(".fusion/memory.md");
});
});
@@ -546,8 +547,8 @@ describe("project-memory", () => {
expect(instructions).toContain("## Project Memory");
// QMD should NOT unconditionally reference .fusion/memory.md
expect(instructions).not.toContain(".fusion/memory.md");
// Should instruct to consult project memory at start
expect(instructions).toMatch(/consult.*project memory/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
});
it("QMD execution instructions completeness", () => {
@@ -555,8 +556,7 @@ describe("project-memory", () => {
const instructions = buildExecutionMemoryInstructions(testDir, settings);
expect(instructions).toContain("## Project Memory");
expect(instructions).not.toContain(".fusion/memory.md");
// Contains consult guidance at start of execution
expect(instructions).toMatch(/consult.*memory/i);
expect(instructions).toContain("memory_search");
// Contains "end of execution" write guidance
expect(instructions).toMatch(/end of execution/i);
// Contains "skip" wording for when nothing durable learned
@@ -565,10 +565,11 @@ describe("project-memory", () => {
expect(instructions).toMatch(/trivia|avoid/i);
});
it("maintains backward compatibility when settings omitted (file behavior)", () => {
it("defaults to qmd guidance when settings are omitted", () => {
const instructions = buildExecutionMemoryInstructions(testDir);
expect(instructions).toContain(".fusion/memory.md");
expect(instructions).toMatch(/read.*memory\.md/i);
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
expect(instructions).not.toContain(".fusion/memory.md");
expect(instructions).toMatch(/end of execution|before calling.*task_done/i);
});
@@ -580,4 +581,20 @@ describe("project-memory", () => {
expect(instructions).not.toContain("\\`- \\`");
});
});
describe("searchProjectMemory", () => {
it("uses qmd backend by default and searches all memory files", async () => {
const memoryDir = join(testDir, ".fusion", "memory");
await mkdir(memoryDir, { recursive: true });
const token = `qmdindexunique${Date.now()}`;
await writeFile(join(memoryDir, "DREAMS.md"), `# Dreams\n\n- The scheduler retries ${token} failures.`, "utf-8");
await writeFile(join(memoryDir, "MEMORY.md"), "# Memory\n\n- Durable API decisions live here.", "utf-8");
const results = await searchProjectMemory(testDir, { query: token, limit: 5 });
expect(results.length).toBeGreaterThan(0);
expect(results[0].backend).toBe("qmd");
expect(results.some((result) => result.path === ".fusion/memory/DREAMS.md")).toBe(true);
});
});
});

View File

@@ -165,7 +165,7 @@ export function resolveMemoryInstructionContext(
// Synchronous resolution using getMemoryBackendCapabilities
// This avoids the async import but requires synchronous access to capabilities
// For file backend (default), we can inline the capabilities
const backendType = settings?.memoryBackendType || "file";
const backendType = settings?.memoryBackendType || "qmd";
switch (backendType) {
case "readonly":
@@ -195,7 +195,6 @@ export function resolveMemoryInstructionContext(
instructionPathHint: null,
};
case "file":
default:
return {
backendType: "file",
backendName: "File (.fusion/memory.md)",
@@ -208,6 +207,19 @@ export function resolveMemoryInstructionContext(
},
instructionPathHint: ".fusion/memory.md",
};
default:
return {
backendType: "qmd",
backendName: "QMD (Quantized Memory Distillation)",
capabilities: {
readable: true,
writable: true,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: true,
},
instructionPathHint: null,
};
}
}
@@ -414,8 +426,9 @@ Do not read all memory or read \`.fusion/memory.md\` directly by default. If mem
This project has a memory system that stores durable project learnings.
**Before writing the specification:**
1. Consult the project memory for relevant context
2. Incorporate any useful learnings into your specification
1. Use \`memory_search\` first for task-relevant context
2. Use \`memory_get\` only for specific memory files/line ranges returned by search
3. Incorporate useful learnings into your specification
**If the memory contains useful context for this task, reference it in the specification.**
`;
@@ -513,8 +526,9 @@ This project has OpenClaw-style memory files:
This project has a memory system that stores durable project learnings accumulated from past task runs.
**At the start of execution:**
1. Consult the project memory for relevant context
2. Apply any useful learnings to your implementation
1. Use \`memory_search\` first for task-relevant context
2. Use \`memory_get\` only for specific memory files/line ranges returned by search
3. Apply useful learnings to your implementation
**At the end of execution (before calling \`task_done()\`):**
1. Review what you learned during this task that would genuinely benefit future runs
@@ -529,6 +543,7 @@ This project has a memory system that stores durable project learnings accumulat
- Transient failures resolved without broader lessons
- One-off file paths, variable names, or minor code changes
- Notes about what you did rather than what future agents should know
5. Consolidate when possible: refine an existing memory entry instead of adding duplicates.
`;
}

View File

@@ -126,7 +126,7 @@ export const DEFAULT_PROJECT_SETTINGS = {
insightExtractionSchedule: "0 2 * * *",
insightExtractionMinIntervalMs: 86_400_000,
memoryEnabled: true,
memoryBackendType: "file",
memoryBackendType: "qmd",
memoryAutoSummarizeEnabled: false,
memoryAutoSummarizeThresholdChars: 50_000,
memoryAutoSummarizeSchedule: "0 3 * * *",

View File

@@ -1265,11 +1265,11 @@ export interface ProjectSettings {
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
* - "qmd": QMD (Quantized Memory Distillation) backend using the qmd CLI tool (default)
* - "file": File-based backend storing memory in `.fusion/memory.md`
* - "readonly": Read-only backend that returns empty memory (for external management)
* - Any registered custom backend type
* Default: "file" */
* Default: "qmd" */
memoryBackendType?: string;
/** When true, enables automatic AI-powered summarization and compression of the
* working memory file when it exceeds the configured size threshold.
@@ -1290,7 +1290,7 @@ export interface ProjectSettings {
* Default: false. */
memoryDreamsEnabled?: boolean;
/** Cron expression for dream processing. Only used when memoryDreamsEnabled
* is true. Default: "0 4 * * *" (daily at 4 AM, after long-term compaction). */
* is true. Default: "0 4 * * *" (daily at 4 AM). */
memoryDreamsSchedule?: string;
/** Maximum token count before auto-compact triggers. When undefined, compact
* only on overflow errors. When set, the engine monitors token usage after