fix(FN-000): align qmd memory lifecycle
This commit is contained in:
5
.changeset/fix-qmd-memory-lifecycle.md
Normal file
5
.changeset/fix-qmd-memory-lifecycle.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Update qmd install guidance and project memory indexing lifecycle.
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2358,6 +2358,7 @@ export class TaskExecutor {
|
||||
agentPrompts: settings.agentPrompts,
|
||||
agentStore: this.options.agentStore,
|
||||
rootDir: this.rootDir,
|
||||
settings,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user