feat(FN-3197): refresh qmd after agent dream writes and normalize agent-mem
This merge normalizes agent-memory paths in the core memory backend and ensures the qmd gets refreshed after agent dream writes, fixing a bug where stale paths could persist after memory updates. The engine's agent-tools module was updated to integrate with this fix, and a changeset was included for Fusion-Task-Id: FN-3197
This commit is contained in:
5
.changeset/fix-qmd-agent-memory-refresh.md
Normal file
5
.changeset/fix-qmd-agent-memory-refresh.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Fix QMD-backed agent memory behavior so search results normalize to readable agent-memory paths and dream-processing writes trigger agent-memory QMD refreshes for discoverability.
|
||||
@@ -343,6 +343,10 @@ const backend = resolveMemoryBackend(settings);
|
||||
**QMD Backend Behavior:**
|
||||
The QMD backend (`qmd`) delegates read/write I/O to the file backend and schedules background QMD index refreshes. For search, it attempts QMD query first and falls back to local `.fusion/memory/` file search when QMD is unavailable, errors, or returns no matches.
|
||||
|
||||
QMD-backed memory behavior also applies to agent-private memory workspaces under `.fusion/agent-memory/{agentId}/`:
|
||||
- Agent memory search normalizes QMD hit paths (including `qmd://...`, absolute paths, and relative filenames) into canonical readable workspace paths (`MEMORY.md`, `DREAMS.md`, `YYYY-MM-DD.md`) so results can be passed directly into `fn_memory_get`.
|
||||
- Agent-memory writes from tool and non-tool paths (including `processAgentMemoryDreams()`) schedule agent-specific QMD refreshes so new dreams/long-term updates remain discoverable without manual reindexing.
|
||||
|
||||
**Dashboard API:**
|
||||
- `GET /api/memory/backend` — Returns current backend status and capabilities
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
processAgentMemoryDreams,
|
||||
syncMemoryDreamsAutomation,
|
||||
} from "../memory-dreams.js";
|
||||
import * as memoryBackend from "../memory-backend.js";
|
||||
|
||||
describe("extractDreamProcessorResult", () => {
|
||||
it("parses dreams and long-term updates from well-formed output", () => {
|
||||
@@ -151,4 +152,30 @@ describe("memory-dreams automation", () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("schedules qmd refresh for processed agent memory when qmd backend is enabled", async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "agent-dreams-qmd-"));
|
||||
const refreshSpy = vi.spyOn(memoryBackend, "scheduleQmdAgentMemoryRefresh").mockImplementation(() => {});
|
||||
try {
|
||||
const date = new Date("2026-04-17T12:00:00.000Z");
|
||||
const agent = {
|
||||
id: "ceo-agent",
|
||||
name: "CEO",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: {},
|
||||
createdAt: date.toISOString(),
|
||||
updatedAt: date.toISOString(),
|
||||
} as any;
|
||||
|
||||
await processAgentMemoryDreams(rootDir, [agent], async () => (
|
||||
"## DREAMS\n\nDream signal.\n\n## LONG_TERM_UPDATES\n\n- Durable update."
|
||||
), date, { memoryBackendType: "qmd" });
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledWith(rootDir, "ceo-agent");
|
||||
} finally {
|
||||
refreshSpy.mockRestore();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ type ExecFileAsync = (
|
||||
) => Promise<{ stdout: string; stderr: string }>;
|
||||
|
||||
const qmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
|
||||
const qmdAgentRefreshState = new Map<string, { lastStartedAt: number; inFlight?: Promise<void> }>();
|
||||
let qmdInstallPromise: Promise<boolean> | null = null;
|
||||
|
||||
export function shouldSkipBackgroundQmdRefresh(): boolean {
|
||||
@@ -494,6 +495,13 @@ export function buildQmdRefreshCommands(rootDir: string): string[][] {
|
||||
];
|
||||
}
|
||||
|
||||
export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string {
|
||||
const absoluteRoot = resolve(rootDir);
|
||||
const safeAgentId = agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
||||
const hash = createHash("sha1").update(`${absoluteRoot}:${agentId}`).digest("hex").slice(0, 12);
|
||||
return `fusion-agent-memory-${safeAgentId.toLowerCase()}-${hash}`;
|
||||
}
|
||||
|
||||
export function dailyMemoryPath(rootDir: string, date = new Date()): string {
|
||||
return join(memoryWorkspacePath(rootDir), `${date.toISOString().slice(0, 10)}.md`);
|
||||
}
|
||||
@@ -1100,6 +1108,70 @@ export function scheduleQmdProjectMemoryRefresh(rootDir: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshQmdAgentMemoryIndex(
|
||||
rootDir: string,
|
||||
agentId: string,
|
||||
options?: { force?: boolean; execFileAsync?: ExecFileAsync },
|
||||
): Promise<void> {
|
||||
const key = `${resolve(rootDir)}:${agentId}`;
|
||||
const now = Date.now();
|
||||
const current = qmdAgentRefreshState.get(key);
|
||||
|
||||
if (!options?.force) {
|
||||
if (current?.inFlight) {
|
||||
return current.inFlight;
|
||||
}
|
||||
if (current && now - current.lastStartedAt < QMD_REFRESH_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const execFileAsync = options?.execFileAsync ?? await getDefaultExecFileAsync();
|
||||
const { agentMemoryWorkspacePath } = await import("./memory-dreams.js");
|
||||
const workspacePath = agentMemoryWorkspacePath(rootDir, agentId);
|
||||
await mkdir(workspacePath, { recursive: true });
|
||||
|
||||
try {
|
||||
await execFileAsync("qmd", ["collection", "add", workspacePath, "--name", qmdAgentMemoryCollectionName(rootDir, agentId), "--mask", "**/*.md"], {
|
||||
cwd: rootDir,
|
||||
timeout: 4000,
|
||||
maxBuffer: 512 * 1024,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const stderr = typeof err === "object" && err && "stderr" in err ? String((err as { stderr?: unknown }).stderr ?? "") : "";
|
||||
if (!/already exists|exists/i.test(`${message}\n${stderr}`)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await execFileAsync("qmd", ["update"], { cwd: rootDir, timeout: 30_000, maxBuffer: 1024 * 1024 });
|
||||
await execFileAsync("qmd", ["embed"], { cwd: rootDir, timeout: 120_000, maxBuffer: 1024 * 1024 });
|
||||
})();
|
||||
|
||||
qmdAgentRefreshState.set(key, { lastStartedAt: now, inFlight: promise });
|
||||
|
||||
try {
|
||||
await promise;
|
||||
} finally {
|
||||
const latest = qmdAgentRefreshState.get(key);
|
||||
if (latest?.inFlight === promise) {
|
||||
qmdAgentRefreshState.set(key, { lastStartedAt: latest.lastStartedAt });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleQmdAgentMemoryRefresh(rootDir: string, agentId: string): void {
|
||||
if (shouldSkipBackgroundQmdRefresh()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void refreshQmdAgentMemoryIndex(rootDir, agentId).catch(() => {
|
||||
// qmd is optional. Search falls back to local file scanning when refresh fails.
|
||||
});
|
||||
}
|
||||
|
||||
export async function isQmdAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const execFileAsync = await getDefaultExecFileAsync();
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
ensureOpenClawMemoryFiles,
|
||||
memoryDreamsPath,
|
||||
memoryLongTermPath,
|
||||
resolveMemoryBackend,
|
||||
scheduleQmdAgentMemoryRefresh,
|
||||
} from "./memory-backend.js";
|
||||
import type { ScheduledTaskCreateInput } from "./automation.js";
|
||||
import type { Agent, ProjectSettings } from "./types.js";
|
||||
@@ -184,6 +186,7 @@ export async function processAgentMemoryDreams(
|
||||
agents: Agent[],
|
||||
executePrompt: DreamPromptExecutor,
|
||||
date = new Date(),
|
||||
settings?: Partial<ProjectSettings>,
|
||||
): Promise<AgentDreamProcessorResult[]> {
|
||||
const dateKey = date.toISOString().slice(0, 10);
|
||||
const results: AgentDreamProcessorResult[] = [];
|
||||
@@ -217,6 +220,9 @@ export async function processAgentMemoryDreams(
|
||||
}
|
||||
await writeFile(dailyPath, `# Agent Daily Memory ${dateKey}\n\n<!-- Processed into dreams on ${new Date().toISOString()} -->\n`, "utf-8");
|
||||
results.push({ agentId: agent.id, ...result });
|
||||
if (resolveMemoryBackend(settings).type === "qmd") {
|
||||
scheduleQmdAgentMemoryRefresh(rootDir, agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
@@ -428,7 +428,7 @@ describe("createMemoryTools", () => {
|
||||
score: 0.8,
|
||||
},
|
||||
{
|
||||
path: "qmd://fusion-agent-memory/.fusion/agent-memory/ceo-agent/2026-05-01.md",
|
||||
path: join(tempDir, ".fusion", "agent-memory", "ceo-agent", "2026-05-01.md"),
|
||||
snippet: "Daily note about delegation follow-up",
|
||||
lineStart: 1,
|
||||
lineEnd: 2,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { join, relative, resolve } from "node:path";
|
||||
import type { AgentStore, AgentState, AgentCapability, AgentUpdateInput, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput } from "@fusion/core";
|
||||
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, resolveResearchSettings, resolveTitleSummarizerSettingsModel, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh, summarizeTitle } from "@fusion/core";
|
||||
import { ResearchOrchestrator } from "./research-orchestrator.js";
|
||||
@@ -408,11 +408,19 @@ function normalizeQmdAgentMemoryResultPath(rootDir: string, agentId: string, raw
|
||||
return resolveAgentMemoryPath(rootDir, agentId, candidate)?.displayPath ?? fallbackPath;
|
||||
}
|
||||
|
||||
const filename = candidate.split("/").pop()?.toLowerCase() ?? "";
|
||||
if (filename === AGENT_MEMORY_FILENAME.toLowerCase()) {
|
||||
const workspacePath = resolve(agentMemoryDirectory(rootDir, agentId)).replace(/\\/g, "/");
|
||||
const candidateAbs = resolve(rootDir, candidate).replace(/\\/g, "/");
|
||||
const relToWorkspace = relative(workspacePath, candidateAbs).replace(/\\/g, "/");
|
||||
if (relToWorkspace && !relToWorkspace.startsWith("..") && !relToWorkspace.includes("/../")) {
|
||||
const maybeDisplayPath = `${agentPrefix}${relToWorkspace}`;
|
||||
return resolveAgentMemoryPath(rootDir, agentId, maybeDisplayPath)?.displayPath ?? fallbackPath;
|
||||
}
|
||||
|
||||
const filename = candidate.split("/").pop() ?? "";
|
||||
if (filename.toLowerCase() === AGENT_MEMORY_FILENAME.toLowerCase()) {
|
||||
return agentMemoryDisplayPath(agentId);
|
||||
}
|
||||
if (filename === AGENT_DREAMS_FILENAME.toLowerCase()) {
|
||||
if (filename.toLowerCase() === AGENT_DREAMS_FILENAME.toLowerCase()) {
|
||||
return agentDreamsDisplayPath(agentId);
|
||||
}
|
||||
if (DAILY_AGENT_MEMORY_RE.test(filename)) {
|
||||
|
||||
Reference in New Issue
Block a user