fix(FN-000): align qmd memory lifecycle

This commit is contained in:
gsxdsm
2026-04-17 09:41:05 -07:00
parent 28accdb085
commit 383033fcdc
26 changed files with 388 additions and 73 deletions

View File

@@ -450,6 +450,10 @@ export {
buildQmdRefreshCommands,
refreshQmdProjectMemoryIndex,
scheduleQmdProjectMemoryRefresh,
installQmd,
ensureQmdInstalled,
ensureQmdInstalledAndRefresh,
scheduleQmdInstallAndRefresh,
dailyMemoryPath,
getDefaultLongTermMemoryScaffold,
getDefaultDailyMemoryScaffold,

View File

@@ -18,8 +18,15 @@ import {
memoryExists,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
QMD_INSTALL_COMMAND,
buildQmdSearchArgs,
buildQmdCollectionAddArgs,
buildQmdRefreshCommands,
refreshQmdProjectMemoryIndex,
installQmd,
ensureQmdInstalled,
qmdMemoryCollectionName,
QMD_REFRESH_INTERVAL_MS,
} from "./memory-backend.js";
import type { MemoryBackend } from "./memory-backend.js";
@@ -417,6 +424,103 @@ describe("memory-backend", () => {
]);
});
it("builds qmd collection args for the project memory workspace", () => {
const args = buildQmdCollectionAddArgs(tempDir);
expect(args).toEqual([
"collection",
"add",
join(tempDir, ".fusion", "memory"),
"--name",
qmdMemoryCollectionName(tempDir),
"--mask",
"**/*.md",
]);
});
it("builds qmd refresh commands in update then embed order", () => {
expect(buildQmdRefreshCommands(tempDir)).toEqual([
buildQmdCollectionAddArgs(tempDir),
["update"],
["embed"],
]);
});
it("refreshQmdProjectMemoryIndex runs collection add, update, and embed", async () => {
const calls: Array<{ file: string; args: readonly string[] }> = [];
const execFileAsync = vi.fn(async (file: string, args: readonly string[]) => {
calls.push({ file, args });
return { stdout: "", stderr: "" };
});
await refreshQmdProjectMemoryIndex(tempDir, { force: true, execFileAsync });
expect(calls).toEqual([
{ file: "qmd", args: buildQmdCollectionAddArgs(tempDir) },
{ file: "qmd", args: ["update"] },
{ file: "qmd", args: ["embed"] },
]);
});
it("refreshQmdProjectMemoryIndex is throttled to the refresh interval", async () => {
vi.useFakeTimers();
const execFileAsync = vi.fn(async () => ({ stdout: "", stderr: "" }));
try {
await refreshQmdProjectMemoryIndex(tempDir, { force: true, execFileAsync });
await refreshQmdProjectMemoryIndex(tempDir, { execFileAsync });
expect(execFileAsync).toHaveBeenCalledTimes(3);
vi.advanceTimersByTime(QMD_REFRESH_INTERVAL_MS + 1);
await refreshQmdProjectMemoryIndex(tempDir, { execFileAsync });
expect(execFileAsync).toHaveBeenCalledTimes(6);
} finally {
vi.useRealTimers();
}
});
it("uses the OpenClaw qmd package install command", () => {
expect(QMD_INSTALL_COMMAND).toBe("bun install -g @tobilu/qmd");
});
it("installQmd runs the configured package install command", async () => {
const execFileAsync = vi.fn(async () => ({ stdout: "", stderr: "" }));
await expect(installQmd({ execFileAsync })).resolves.toBe(true);
expect(execFileAsync).toHaveBeenCalledWith("bun", ["install", "-g", "@tobilu/qmd"], {
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
});
it("ensureQmdInstalled skips install when qmd is already available", async () => {
const execFileAsync = vi.fn(async () => ({ stdout: "", stderr: "" }));
const isAvailable = vi.fn(async () => true);
await expect(ensureQmdInstalled({ execFileAsync, isAvailable })).resolves.toBe(true);
expect(isAvailable).toHaveBeenCalledOnce();
expect(execFileAsync).not.toHaveBeenCalled();
});
it("ensureQmdInstalled installs qmd when it is missing", async () => {
const execFileAsync = vi.fn(async () => ({ stdout: "", stderr: "" }));
const isAvailable = vi.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
await expect(ensureQmdInstalled({ execFileAsync, isAvailable })).resolves.toBe(true);
expect(isAvailable).toHaveBeenCalledTimes(2);
expect(execFileAsync).toHaveBeenCalledWith("bun", ["install", "-g", "@tobilu/qmd"], {
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
});
it("clamps qmd result limits", () => {
expect(buildQmdSearchArgs(tempDir, { query: "memory", limit: 999 })).toContain("20");
expect(buildQmdSearchArgs(tempDir, { query: "memory", limit: 0 })).toContain("1");

View File

@@ -34,6 +34,7 @@ type ExecFileAsync = (
) => Promise<{ stdout: string; stderr: string }>;
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
let qmdInstallPromise: Promise<boolean> | null = null;
// ── Type Definitions ────────────────────────────────────────────────
@@ -972,9 +973,7 @@ export function scheduleQmdProjectMemoryRefresh(rootDir: string): void {
export async function isQmdAvailable(): Promise<boolean> {
try {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const execFileAsync = promisify(execFile);
const execFileAsync = await getDefaultExecFileAsync();
await execFileAsync("qmd", ["--help"], {
timeout: 3000,
maxBuffer: 128 * 1024,
@@ -985,6 +984,56 @@ export async function isQmdAvailable(): Promise<boolean> {
}
}
export async function installQmd(
options?: { execFileAsync?: ExecFileAsync },
): Promise<boolean> {
const execFileAsync = options?.execFileAsync ?? await getDefaultExecFileAsync();
const [command, ...args] = QMD_INSTALL_COMMAND.split(" ");
if (!command || args.length === 0) {
throw new MemoryBackendError("BACKEND_UNAVAILABLE", "qmd install command is not configured", "qmd");
}
await execFileAsync(command, args, {
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
return true;
}
export async function ensureQmdInstalled(
options?: {
execFileAsync?: ExecFileAsync;
isAvailable?: () => Promise<boolean>;
},
): Promise<boolean> {
const checkAvailable = options?.isAvailable ?? isQmdAvailable;
if (await checkAvailable()) {
return true;
}
if (!qmdInstallPromise) {
qmdInstallPromise = installQmd({ execFileAsync: options?.execFileAsync })
.then(async () => checkAvailable())
.finally(() => {
qmdInstallPromise = null;
});
}
return qmdInstallPromise;
}
export async function ensureQmdInstalledAndRefresh(rootDir: string): Promise<void> {
const available = await ensureQmdInstalled();
if (available) {
await refreshQmdProjectMemoryIndex(rootDir, { force: true });
}
}
export function scheduleQmdInstallAndRefresh(rootDir: string): void {
void ensureQmdInstalledAndRefresh(rootDir).catch(() => {
// qmd remains optional at runtime. Search falls back to local file scanning.
});
}
// ── Backend Registration ─────────────────────────────────────────────
/**

View File

@@ -11,6 +11,7 @@ import {
ensureMemoryFileWithBackend,
buildTriageMemoryInstructions,
buildExecutionMemoryInstructions,
buildReviewerMemoryInstructions,
readProjectMemory,
readProjectMemoryWithBackend,
searchProjectMemory,
@@ -70,6 +71,22 @@ describe("project-memory", () => {
});
});
describe("buildReviewerMemoryInstructions", () => {
it("gives reviewers read-only project memory guidance", () => {
const instructions = buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" });
expect(instructions).toContain("## Project Memory");
expect(instructions).toContain("memory_search");
expect(instructions).toContain("memory_get");
expect(instructions).toContain("review evidence");
expect(instructions).toContain("Do not update memory during review");
});
it("omits reviewer memory guidance when memory is disabled", () => {
expect(buildReviewerMemoryInstructions(testDir, { memoryEnabled: false })).toBe("");
});
});
// ── ensureMemoryFile ──────────────────────────────────────────────
describe("ensureMemoryFile", () => {

View File

@@ -120,7 +120,7 @@ async function getMemoryBackendUtils() {
getMemoryBackendCapabilities: module.getMemoryBackendCapabilities,
MEMORY_BACKEND_SETTINGS_KEYS: module.MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND: module.DEFAULT_MEMORY_BACKEND,
scheduleQmdProjectMemoryRefresh: module.scheduleQmdProjectMemoryRefresh,
scheduleQmdInstallAndRefresh: module.scheduleQmdInstallAndRefresh,
};
}
@@ -163,6 +163,21 @@ export interface MemoryInstructionContext {
export function resolveMemoryInstructionContext(
settings?: MemorySettings,
): MemoryInstructionContext {
if (settings?.memoryEnabled === false) {
return {
backendType: "disabled",
backendName: "Disabled",
capabilities: {
readable: false,
writable: false,
supportsAtomicWrite: false,
hasConflictResolution: false,
persistent: false,
},
instructionPathHint: null,
};
}
// Synchronous resolution using getMemoryBackendCapabilities
// This avoids the async import but requires synchronous access to capabilities
// For file backend (default), we can inline the capabilities
@@ -244,7 +259,7 @@ export async function ensureMemoryFileWithBackend(
resolveMemoryBackend,
MEMORY_BACKEND_SETTINGS_KEYS,
DEFAULT_MEMORY_BACKEND,
scheduleQmdProjectMemoryRefresh,
scheduleQmdInstallAndRefresh,
} = await getMemoryBackendUtils();
const backendType =
@@ -253,7 +268,7 @@ export async function ensureMemoryFileWithBackend(
const backend = resolveMemoryBackend(settings);
const refreshQmdIfNeeded = () => {
if (backend.type === "qmd" || backendType === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
scheduleQmdInstallAndRefresh(rootDir);
}
};