fix(FN-000): align qmd memory lifecycle
This commit is contained in:
@@ -450,6 +450,10 @@ export {
|
||||
buildQmdRefreshCommands,
|
||||
refreshQmdProjectMemoryIndex,
|
||||
scheduleQmdProjectMemoryRefresh,
|
||||
installQmd,
|
||||
ensureQmdInstalled,
|
||||
ensureQmdInstalledAndRefresh,
|
||||
scheduleQmdInstallAndRefresh,
|
||||
dailyMemoryPath,
|
||||
getDefaultLongTermMemoryScaffold,
|
||||
getDefaultDailyMemoryScaffold,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user