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

@@ -0,0 +1,5 @@
---
"@gsxdsm/fusion": patch
---
Update qmd install guidance and project memory indexing lifecycle.

View File

@@ -23,7 +23,7 @@ vi.mock("@fusion/core", () => ({
updateProject: mockUpdateProject,
})),
isQmdAvailable: vi.fn(() => Promise.resolve(true)),
QMD_INSTALL_COMMAND: "bun add -g qmd",
QMD_INSTALL_COMMAND: "bun install -g @tobilu/qmd",
resolveGlobalDir: vi.fn(),
}));

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);
}
};

View File

@@ -3522,7 +3522,7 @@ describe("installQmd", () => {
const response = {
success: true,
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -3551,7 +3551,7 @@ describe("installQmd", () => {
const response = {
success: true,
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({

View File

@@ -64,7 +64,7 @@ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "employees", label: "Employees", icon: GitBranch },
{ id: "soul", label: "Soul", icon: Heart },
{ id: "instructions", label: "Instructions", icon: BookOpen },
{ id: "memory", label: "Memory", icon: FileText },
{ id: "memory", label: "Agent Memory", icon: FileText },
{ id: "reflections", label: "Reflections", icon: BarChart3 },
{ id: "performance", label: "Performance", icon: Star },
{ id: "config", label: "Settings", icon: Settings },
@@ -1697,9 +1697,9 @@ function MemoryTab({
return (
<div className="config-tab">
<div className="config-section">
<h3>Memory</h3>
<h3>Agent Memory</h3>
<p className="config-description">
Store accumulated context and learnings for this agent.
Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.
</p>
{isReadOnly && (
<p className="config-hint" style={{ marginBottom: 12 }}>
@@ -1714,7 +1714,7 @@ function MemoryTab({
id="agent-memory"
className="input"
rows={15}
placeholder="Agent's accumulated knowledge, learnings, and preferences..."
placeholder="Durable preferences, operating habits, and context this agent should carry across tasks..."
value={memory}
readOnly={isReadOnly}
onChange={(e) => {
@@ -1723,7 +1723,7 @@ function MemoryTab({
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Per-agent memory stores learnings and context the agent has gathered. Max 50,000 characters.</span>
<span className="config-hint">This is injected as Agent Memory in the prompt and kept separate from workspace Project Memory. Max 50,000 characters.</span>
</div>
</div>

View File

@@ -326,12 +326,12 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
/>
</div>
<div className="agent-dialog-field">
<label htmlFor="agent-memory">Memory <span className="agent-dialog-optional">(optional)</span></label>
<label htmlFor="agent-memory">Agent Memory <span className="agent-dialog-optional">(optional)</span></label>
<textarea
id="agent-memory"
className="input"
rows={2}
placeholder="Per-agent memory — stores learnings and context the agent has gathered..."
placeholder="Private to this agent — durable preferences, operating habits, and context it should carry across tasks..."
value={memory}
onChange={e => setMemory(e.target.value)}
/>

View File

@@ -129,13 +129,13 @@ describe("SettingsModal", () => {
query: "pattern",
qmdAvailable: true,
usedFallback: false,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
results: [],
});
mockInstallQmd.mockResolvedValue({
success: true,
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
});
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
@@ -151,7 +151,7 @@ describe("SettingsModal", () => {
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
});
mockUseMemoryBackendStatus.mockReturnValue({
status: {
@@ -165,7 +165,7 @@ describe("SettingsModal", () => {
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "file",
capabilities: {
@@ -459,7 +459,7 @@ describe("SettingsModal", () => {
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: false,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "qmd",
capabilities: {

View File

@@ -2271,7 +2271,7 @@ export function SettingsModal({
<div className="settings-empty-state memory-status-message">
<span>
qmd is not installed. Search will use local files.
Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun add -g qmd"}</code>
Install indexed retrieval: <code>{backendStatus.qmdInstallCommand || "bun install -g @tobilu/qmd"}</code>
</span>
<button
type="button"

View File

@@ -486,7 +486,7 @@ describe("AgentDetailView", () => {
expect(screen.getByText("Employees")).toBeInTheDocument();
expect(screen.getByText("Soul")).toBeInTheDocument();
expect(screen.getByText("Instructions")).toBeInTheDocument();
expect(screen.getByText("Memory")).toBeInTheDocument();
expect(screen.getByText("Agent Memory")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
});

View File

@@ -103,12 +103,12 @@ vi.mock("../../api", () => ({
path: ".fusion/memory/DREAMS.md",
content: "# Compacted Memory\n\nImportant content.",
})),
installQmd: vi.fn(() => Promise.resolve({ success: true, qmdAvailable: true, qmdInstallCommand: "bun add -g qmd" })),
installQmd: vi.fn(() => Promise.resolve({ success: true, qmdAvailable: true, qmdInstallCommand: "bun install -g @tobilu/qmd" })),
testMemoryRetrieval: vi.fn(() => Promise.resolve({
query: "project memory",
qmdAvailable: true,
usedFallback: false,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
results: [],
})),
}));
@@ -127,7 +127,7 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "file",
capabilities: {
@@ -158,7 +158,7 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "file",
capabilities: {

View File

@@ -69,12 +69,12 @@ vi.mock("../../api", () => ({
})),
fetchMemoryFile: vi.fn((path = ".fusion/memory/DREAMS.md") => Promise.resolve({ path, content: "" })),
saveMemoryFile: vi.fn(() => Promise.resolve({ success: true })),
installQmd: vi.fn(() => Promise.resolve({ success: true, qmdAvailable: true, qmdInstallCommand: "bun add -g qmd" })),
installQmd: vi.fn(() => Promise.resolve({ success: true, qmdAvailable: true, qmdInstallCommand: "bun install -g @tobilu/qmd" })),
testMemoryRetrieval: vi.fn(() => Promise.resolve({
query: "project memory",
qmdAvailable: true,
usedFallback: false,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
results: [],
})),
fetchGlobalConcurrency: vi.fn(() => Promise.resolve({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} })),
@@ -90,7 +90,7 @@ vi.mock("../../api", () => ({
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
})),
}));
@@ -107,7 +107,7 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
},
availableBackends: ["file", "readonly", "qmd"],
qmdAvailable: true,
qmdInstallCommand: "bun add -g qmd",
qmdInstallCommand: "bun install -g @tobilu/qmd",
},
currentBackend: "file",
capabilities: {

View File

@@ -18,7 +18,7 @@ import * as nodeFs from "node:fs";
import { promisify } from "node:util";
import type { TaskStore, Column, ScheduleType, ActivityEventType, ModelPreset, MessageType, ParticipantType, RoutineTriggerType, ProjectSettings } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, QMD_INSTALL_COMMAND, MemoryBackendError, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, type PiExtensionEntry, type PiExtensionSettings, getCurrentRepo, isGhAuthenticated, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupRoutine, exportSettings, importSettings, validateImportData, MessageStore, RoutineStore, isWebhookTrigger, resolveMemoryBackend, getMemoryBackendCapabilities, listMemoryBackendTypes, listProjectMemoryFiles, readProjectMemoryFile, readProjectMemoryFileContent, writeProjectMemoryFile, readMemory, writeMemory, searchProjectMemory, isQmdAvailable, installQmd, refreshQmdProjectMemoryIndex, QMD_INSTALL_COMMAND, MemoryBackendError, scheduleQmdProjectMemoryRefresh, discoverPiExtensions, updatePiExtensionDisabledIds, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -2596,6 +2596,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const rootDir = scopedStore.getRootDir();
await writeProjectMemoryFile(rootDir, path, content);
if (backend.type === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
}
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -2656,21 +2659,18 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
router.post("/memory/install-qmd", async (_req, res) => {
router.post("/memory/install-qmd", async (req, res) => {
try {
const [command, ...args] = QMD_INSTALL_COMMAND.split(" ");
if (!command || args.length === 0) {
throw new Error("qmd install command is not configured");
const { store: scopedStore } = await getProjectContext(req);
const installed = await installQmd();
const qmdAvailable = await isQmdAvailable();
if (installed && qmdAvailable) {
scheduleQmdProjectMemoryRefresh(scopedStore.getRootDir());
}
await execFileAsync(command, args, {
timeout: 120_000,
maxBuffer: 1024 * 1024,
});
res.json({
success: true,
qmdAvailable: await isQmdAvailable(),
success: installed,
qmdAvailable,
qmdInstallCommand: QMD_INSTALL_COMMAND,
});
} catch (err: unknown) {
@@ -2690,6 +2690,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
? req.body.query.trim()
: "project memory";
const qmdAvailable = await isQmdAvailable();
if (qmdAvailable) {
await refreshQmdProjectMemoryIndex(rootDir, { force: true });
}
const results = await searchProjectMemory(
rootDir,
{ query, limit: 5 },
@@ -2768,6 +2771,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Write compacted content back to the same selected memory file.
await writeProjectMemoryFile(rootDir, result.path, compacted);
if (backend.type === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
}
res.json({ path: result.path, content: compacted });
} catch (err: unknown) {

View File

@@ -88,14 +88,16 @@ describe("resolveAgentInstructions", () => {
it("returns memory section when memory is set", async () => {
const agent = makeAgent({ memory: "Remember to keep CI green." });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("## Memory\n\nRemember to keep CI green.");
expect(result).toContain("## Agent Memory");
expect(result).toContain("memory for this agent only");
expect(result).toContain("Remember to keep CI green.");
});
it("omits memory section when memory is empty", async () => {
const agent = makeAgent({ instructionsText: "Base instructions", memory: " " });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Base instructions");
expect(result).not.toContain("## Memory");
expect(result).not.toContain("## Agent Memory");
});
it("returns instructionsText when set", async () => {
@@ -249,12 +251,13 @@ describe("resolveAgentInstructions", () => {
const instructionsTextIndex = result.indexOf("Inline instructions.");
const instructionsFileIndex = result.indexOf("File-based instructions here.");
const soulIndex = result.indexOf("## Soul");
const memoryIndex = result.indexOf("## Memory");
const memoryIndex = result.indexOf("## Agent Memory");
expect(instructionsTextIndex).toBeLessThan(soulIndex);
expect(instructionsFileIndex).toBeLessThan(soulIndex);
expect(soulIndex).toBeLessThan(memoryIndex);
expect(result).toContain("## Memory\n\nRemember that this repository uses pnpm workspaces.");
expect(result).toContain("## Agent Memory");
expect(result).toContain("Remember that this repository uses pnpm workspaces.");
});
});
@@ -311,13 +314,13 @@ describe("resolveAgentInstructions with rating summary", () => {
// Verify section order: instructionsText → soul → memory → Performance Feedback
const instructionsIndex = result.indexOf("Implement the feature.");
const soulIndex = result.indexOf("## Soul");
const memoryIndex = result.indexOf("## Memory");
const memoryIndex = result.indexOf("## Agent Memory");
const feedbackIndex = result.indexOf("## Performance Feedback");
expect(instructionsIndex).toBeLessThan(soulIndex);
expect(soulIndex).toBeLessThan(memoryIndex);
expect(memoryIndex).toBeLessThan(feedbackIndex);
expect(result).toContain("## Memory");
expect(result).toContain("## Agent Memory");
expect(result).toContain("## Performance Feedback");
});
@@ -522,7 +525,8 @@ describe("buildAgentChatPrompt", () => {
);
expect(prompt).toContain("Always include focused tests.");
expect(prompt).toContain("## Soul\n\nBe calm, direct, and empathetic.");
expect(prompt).toContain("## Memory\n\nThe team values short progress updates.");
expect(prompt).toContain("## Agent Memory");
expect(prompt).toContain("The team values short progress updates.");
expect(prompt).toContain("## Project Memory\n\nProject preference: avoid force pushes.");
});
});

View File

@@ -1989,23 +1989,29 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Soul");
expect(callArgs.systemPrompt).toContain("Act like a practical teammate who prioritizes clarity.");
expect(callArgs.systemPrompt).toContain("## Memory");
expect(callArgs.systemPrompt).toContain("## Agent Memory");
expect(callArgs.systemPrompt).toContain("Recent runs found flaky tests in integration suites.");
expect(callArgs.systemPrompt).toContain("Always log blockers with actionable next steps.");
expect(callArgs.systemPrompt).toContain("## Project Memory");
expect(callArgs.systemPrompt).toContain("memory_search");
expect(callArgs.tools).toBe("readonly");
// Tools: task_create, task_log, task_document_write, task_document_read, list_agents, delegate_task, heartbeat_done
expect(callArgs.customTools).toHaveLength(7);
// Tools: task_create, task_log, task_document_write, task_document_read, list_agents, delegate_task,
// memory_search, memory_get, memory_append, heartbeat_done
expect(callArgs.customTools).toHaveLength(10);
expect(callArgs.customTools![0]!.name).toBe("task_create");
expect(callArgs.customTools![1]!.name).toBe("task_log");
expect(callArgs.customTools![2]!.name).toBe("task_document_write");
expect(callArgs.customTools![3]!.name).toBe("task_document_read");
expect(callArgs.customTools![4]!.name).toBe("list_agents");
expect(callArgs.customTools![5]!.name).toBe("delegate_task");
expect(callArgs.customTools![6]!.name).toBe("memory_search");
expect(callArgs.customTools![7]!.name).toBe("memory_get");
expect(callArgs.customTools![8]!.name).toBe("memory_append");
// heartbeat_done is last (terminal tool)
expect(callArgs.customTools![6]!.name).toBe("heartbeat_done");
expect(callArgs.customTools![9]!.name).toBe("heartbeat_done");
});
it("falls back to the base heartbeat prompt when agent has no custom instructions", async () => {
it("includes memory instructions even when agent has no custom instructions", async () => {
const store = createStoreWithAgentForExec({
soul: undefined,
memory: undefined,
@@ -2022,7 +2028,30 @@ describe("HeartbeatMonitor", () => {
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateKbAgent.mock.calls[0]![0];
expect(callArgs.systemPrompt).toBe(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Project Memory");
});
it("omits memory tools and instructions when project memory is disabled", async () => {
const store = createStoreWithAgentForExec();
const taskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ memoryEnabled: false }),
} as Partial<TaskStore>);
const mockSession = createMockAgentSession();
mockedCreateKbAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateKbAgent.mock.calls[0]![0];
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
expect(callArgs.systemPrompt).not.toContain("## Project Memory");
expect(toolNames).not.toContain("memory_search");
expect(toolNames).not.toContain("memory_get");
expect(toolNames).not.toContain("memory_append");
});
it("includes document tools in heartbeat session", async () => {

View File

@@ -17,10 +17,11 @@
* - onTerminated: Called when an unresponsive agent is terminated
*/
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext } from "@fusion/core";
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings } from "@fusion/core";
import { buildExecutionMemoryInstructions } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { heartbeatLog } from "./logger.js";
@@ -139,6 +140,13 @@ You have readonly file access plus task_create, task_log, and task_document tool
**Task Documents:** Save important findings with task_document_write(key="...", content="...").
Documents persist across sessions and are visible in the dashboard's Documents tab.
## Memory Boundaries
You may receive an Agent Memory section and a Project Memory section.
- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. Use it for your durable operating preferences and role context.
- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval.
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.
## Processing Messages
When you are woken by an incoming message (source includes "wake-on-message"), you should:
@@ -159,6 +167,14 @@ const heartbeatDoneParams = Type.Object({
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
});
async function getHeartbeatMemorySettings(taskStore: TaskStore): Promise<Settings | undefined> {
const maybeGetSettings = (taskStore as { getSettings?: () => Promise<Settings> }).getSettings;
if (!maybeGetSettings) {
return undefined;
}
return maybeGetSettings.call(taskStore);
}
/**
* HeartbeatMonitor monitors agents via periodic polling.
* Detects missed heartbeats, auto-terminates unresponsive agents,
@@ -926,6 +942,14 @@ export class HeartbeatMonitor {
// Build tools with task creation tracking and run context for mutation correlation
// Pass messageStore for messaging tools (send_message, read_messages)
const heartbeatTools = this.createHeartbeatTools(agentId, taskStore, taskId, runContext, audit, this.messageStore);
let memorySettings: Settings | undefined;
try {
memorySettings = await getHeartbeatMemorySettings(taskStore);
heartbeatTools.push(...createMemoryTools(rootDir, memorySettings));
} catch (memorySettingsError) {
const message = memorySettingsError instanceof Error ? memorySettingsError.message : String(memorySettingsError);
heartbeatLog.warn(`Failed to configure heartbeat memory tools for ${agentId}: ${message}`);
}
heartbeatTools.push(heartbeatDoneTool);
agentLogger = new AgentLogger({
@@ -940,7 +964,13 @@ export class HeartbeatMonitor {
let systemPrompt = HEARTBEAT_SYSTEM_PROMPT;
try {
const agentInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
systemPrompt = buildSystemPromptWithInstructions(HEARTBEAT_SYSTEM_PROMPT, agentInstructions);
const memoryInstructions = memorySettings?.memoryEnabled === false
? ""
: buildExecutionMemoryInstructions(rootDir, memorySettings);
systemPrompt = buildSystemPromptWithInstructions(
HEARTBEAT_SYSTEM_PROMPT,
[agentInstructions, memoryInstructions].filter((part) => part.trim()).join("\n\n"),
);
} catch (instructionError) {
const message = instructionError instanceof Error ? instructionError.message : String(instructionError);
heartbeatLog.warn(`Failed to enrich heartbeat system prompt for ${agentId}: ${message}`);

View File

@@ -97,7 +97,13 @@ function formatMemorySection(memory: string, agentId: string): string {
if (!trimmed) {
return "";
}
return `## Memory\n\n${trimmed}`;
return [
"## Agent Memory",
"",
"This is memory for this agent only. Keep it separate from workspace Project Memory; use it for durable preferences, operating habits, and context that should follow this agent across tasks.",
"",
trimmed,
].join("\n");
}
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {

View File

@@ -9,7 +9,7 @@
import { appendFile } from "node:fs/promises";
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, searchProjectMemory } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, scheduleQmdProjectMemoryRefresh, searchProjectMemory } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentReflectionService } from "./agent-reflection.js";
@@ -361,7 +361,7 @@ export function createMemoryGetTool(rootDir: string, settings?: MemoryToolSettin
};
}
export function createMemoryAppendTool(rootDir: string): ToolDefinition {
export function createMemoryAppendTool(rootDir: string, settings?: MemoryToolSettings): ToolDefinition {
return {
name: "memory_append",
label: "Append Memory",
@@ -378,6 +378,9 @@ export function createMemoryAppendTool(rootDir: string): ToolDefinition {
}
await appendFile(targetPath, `\n${content}\n`, "utf-8");
if (resolveMemoryBackend(settings).type === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
}
return {
content: [{ type: "text" as const, text: `Appended to ${params.layer} memory.` }],
details: { layer: params.layer },
@@ -395,7 +398,7 @@ export function createMemoryTools(rootDir: string, settings?: MemoryToolSettings
createMemoryGetTool(rootDir, settings),
];
if (getMemoryBackendCapabilities(settings).writable) {
tools.push(createMemoryAppendTool(rootDir));
tools.push(createMemoryAppendTool(rootDir, settings));
}
return tools;
}

View File

@@ -2358,6 +2358,7 @@ export class TaskExecutor {
agentPrompts: settings.agentPrompts,
agentStore: this.options.agentStore,
rootDir: this.rootDir,
settings,
},
);

View File

@@ -215,7 +215,7 @@ describe("promptWithFallback context recovery", () => {
expect(prompt).toHaveBeenCalledTimes(2);
expect(compact).not.toHaveBeenCalled();
expect(prompts[1]!.length).toBeLessThan(prompts[0]!.length);
expect(prompts[1]).toContain("Project memory compacted");
expect(prompts[1]).toContain("Memory compacted");
expect(prompts[1]).toContain("## Begin");
});

View File

@@ -190,12 +190,12 @@ function compactMarkdownMemorySection(sectionBody: string): string {
return [
compacted,
"",
`<!-- Project memory compacted from ${sectionBody.length} characters to avoid context overflow. Read .fusion/memory.md later only if essential. -->`,
`<!-- Memory compacted from ${sectionBody.length} characters to avoid context overflow. Use memory tools or the selected memory file later only if essential. -->`,
].join("\n").trim();
}
function compactPromptMemory(prompt: string): string | null {
const sectionPattern = /(^|\n)(## (?:Project Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
const sectionPattern = /(^|\n)(## (?:Project Memory|Agent Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
let changed = false;
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix: string, heading: string, body: string) => {
const trimmedBody = body.trim();

View File

@@ -146,6 +146,39 @@ describe("reviewStep — spec review type", () => {
expect(opts.systemPrompt).toContain("Mission clarity");
});
it("injects read-only memory instructions and tools when project memory is enabled", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),
);
await reviewStep(
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
undefined,
{ rootDir: "/tmp/project", settings: { memoryBackendType: "qmd" } as any },
);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.systemPrompt).toContain("## Project Memory");
expect(opts.systemPrompt).toContain("Do not update memory during review");
expect(opts.customTools?.map((tool: any) => tool.name)).toEqual(["memory_search", "memory_get"]);
});
it("omits reviewer memory tools and instructions when memory is disabled", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),
);
await reviewStep(
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
undefined,
{ rootDir: "/tmp/project", settings: { memoryEnabled: false } as any },
);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.systemPrompt).not.toContain("## Project Memory");
expect(opts.customTools).toBeUndefined();
});
it("builds review request with spec-specific instructions", async () => {
let capturedPrompt = "";
mockedCreateHaiAgent.mockResolvedValue({

View File

@@ -8,8 +8,8 @@
* - Verdict + feedback is returned to the worker
*/
import type { TaskStore, TaskComment, AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core";
import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { AgentLogger } from "./agent-logger.js";
@@ -228,6 +228,8 @@ export interface ReviewOptions {
agentStore?: import("@fusion/core").AgentStore;
/** Project root directory for resolving relative instructionsPath files. */
rootDir?: string;
/** Project settings used for backend-aware memory tools and instructions. */
settings?: Settings;
}
/**
@@ -305,8 +307,12 @@ export async function reviewStep(
// Graceful fallback
}
}
const reviewerBasePrompt = resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT;
const memorySection = options.rootDir && options.settings?.memoryEnabled !== false
? "\n" + buildReviewerMemoryInstructions(options.rootDir, options.settings)
: "";
const reviewerSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT,
reviewerBasePrompt + memorySection,
reviewerInstructions,
);
@@ -326,14 +332,17 @@ export async function reviewStep(
}
// Spawn a reviewer agent with read-only tools
const memoryTools = options.rootDir && options.settings?.memoryEnabled !== false
? [
createMemorySearchTool(options.rootDir, options.settings),
createMemoryGetTool(options.rootDir, options.settings),
]
: undefined;
const { session } = await createKbAgent({
cwd,
systemPrompt: reviewerSystemPrompt,
tools: "readonly",
customTools: options.rootDir ? [
createMemorySearchTool(options.rootDir),
createMemoryGetTool(options.rootDir),
] : undefined,
customTools: memoryTools,
onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
onThinking: agentLogger?.onThinking,
onToolStart: agentLogger?.onToolStart,