fix(FN-000): simplify qmd memory settings
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 * * *",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user