feat(FN-1910): add detailed QMD memory management workflow

- Add a new Memory view with Working, Insights, and Engines tabs for editing, extraction, audit visibility, and backend status
- Introduce useMemoryData and API client wrappers for insights read/write, extraction triggers, audit reports, and quick memory stats
- Implement dashboard memory routes for /memory/insights, /memory/extract, /memory/audit, and /memory/stats including AI-powered extraction orchestration
- Wire the Memory view into desktop and mobile navigation and persist it in task view state
- Add comprehensive route, API wrapper, and hook tests covering happy paths and validation/error handling
This commit is contained in:
Fusion
2026-04-18 05:11:57 -07:00
committed by gsxdsm
parent 5edd49130c
commit 4341ceb52a
12 changed files with 2396 additions and 9 deletions

View File

@@ -12805,6 +12805,268 @@ describe("POST /api/memory/compact", () => {
});
});
describe("GET /api/memory/insights", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-insights-"));
mkdirSync(join(rootDir, ".fusion"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with content and exists:true when insights file exists", async () => {
// Insights file is at .fusion/memory-insights.md
writeFileSync(join(rootDir, ".fusion", "memory-insights.md"), "## Patterns\n- Pattern 1\n- Pattern 2");
const res = await GET(buildApp(), "/api/memory/insights");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("content");
expect(res.body).toHaveProperty("exists", true);
expect(typeof res.body.content).toBe("string");
});
it("returns 200 with content:null and exists:false when insights file does not exist", async () => {
const res = await GET(buildApp(), "/api/memory/insights");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("content", null);
expect(res.body).toHaveProperty("exists", false);
});
});
describe("PUT /api/memory/insights", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-insights-write-"));
mkdirSync(join(rootDir, ".fusion"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with success:true for valid content", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/memory/insights",
JSON.stringify({ content: "## Patterns\n- New insight" }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("success", true);
// Verify file was written (insights file is .fusion/memory-insights.md)
const insightsPath = join(rootDir, ".fusion", "memory-insights.md");
expect(existsSync(insightsPath)).toBe(true);
});
it("returns 400 when content is missing", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/memory/insights",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("content must be a string");
});
it("returns 400 when content is not a string", async () => {
const res = await REQUEST(
buildApp(),
"PUT",
"/api/memory/insights",
JSON.stringify({ content: 123 }),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("content must be a string");
});
});
describe("POST /api/memory/extract", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-extract-"));
mkdirSync(join(rootDir, ".fusion"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 400 when working memory is empty", async () => {
const res = await REQUEST(
buildApp(),
"POST",
"/api/memory/extract",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(400);
expect(res.body.error).toContain("No working memory");
});
it("returns 200 with extraction result on success", async () => {
// Working memory is at .fusion/memory.md
writeFileSync(join(rootDir, ".fusion", "memory.md"), "Working memory content for extraction that is long enough.");
const res = await REQUEST(
buildApp(),
"POST",
"/api/memory/extract",
JSON.stringify({}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("success", true);
expect(res.body).toHaveProperty("summary");
expect(res.body).toHaveProperty("insightCount");
expect(res.body).toHaveProperty("pruned");
});
});
describe("GET /api/memory/audit", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-audit-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with audit report shape", async () => {
const res = await GET(buildApp(), "/api/memory/audit");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("generatedAt");
expect(res.body).toHaveProperty("workingMemory");
expect(res.body).toHaveProperty("insightsMemory");
expect(res.body).toHaveProperty("extraction");
expect(res.body).toHaveProperty("pruning");
expect(res.body).toHaveProperty("checks");
expect(res.body).toHaveProperty("health");
expect(["healthy", "warning", "issues"]).toContain(res.body.health);
});
it("includes working memory stats in audit", async () => {
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "# Working Memory\n\nSome content.");
const res = await GET(buildApp(), "/api/memory/audit");
expect(res.status).toBe(200);
expect(res.body.workingMemory).toHaveProperty("exists");
expect(res.body.workingMemory).toHaveProperty("size");
expect(res.body.workingMemory).toHaveProperty("sectionCount");
});
});
describe("GET /api/memory/stats", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-stats-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns 200 with workingMemorySize, insightsSize, and insightsExists", async () => {
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Working memory content.");
const res = await GET(buildApp(), "/api/memory/stats");
expect(res.status).toBe(200);
expect(res.body).toHaveProperty("workingMemorySize");
expect(res.body).toHaveProperty("insightsSize");
expect(res.body).toHaveProperty("insightsExists");
expect(typeof res.body.workingMemorySize).toBe("number");
expect(typeof res.body.insightsSize).toBe("number");
expect(typeof res.body.insightsExists).toBe("boolean");
});
it("returns insightsExists:false when insights file does not exist", async () => {
const res = await GET(buildApp(), "/api/memory/stats");
expect(res.status).toBe(200);
expect(res.body.insightsExists).toBe(false);
expect(res.body.insightsSize).toBe(0);
});
});
describe("PUT /api/settings - memoryBackendType validation", () => {
let store: TaskStore;

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, EnrichedChatSession } 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, ensureMemoryFileWithBackend } 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, ensureMemoryFileWithBackend, readWorkingMemory, readInsightsMemory, writeInsightsMemory, generateMemoryAudit, buildInsightExtractionPrompt, parseInsightExtractionResponse, processAndAuditInsightExtraction } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -2810,6 +2810,228 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Memory Insights Routes ───────────────────────────────────────────
// Lazy-loaded createKbAgent for AI operations (same pattern as ai-refine.ts)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createKbAgentForInsights: any;
async function initCreateKbAgentForInsights(): Promise<void> {
if (createKbAgentForInsights) return;
try {
// Use dynamic import with @vite-ignore to prevent static analysis issues
const engine = await import(/* @vite-ignore */ "@fusion/engine");
createKbAgentForInsights = engine.createKbAgent;
} catch {
createKbAgentForInsights = undefined;
}
}
/**
* GET /api/memory/insights
* Returns the insights memory file content.
* Returns { content: null, exists: false } if no insights file exists yet.
*/
router.get("/memory/insights", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const content = await readInsightsMemory(rootDir);
res.json({ content, exists: content !== null });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
// If the file doesn't exist, return null with exists: false
if (err instanceof Error && err.message.includes("no such file")) {
res.json({ content: null, exists: false });
return;
}
rethrowAsApiError(err, "Failed to read memory insights");
}
});
/**
* PUT /api/memory/insights
* Updates the insights memory file content.
* Body: { content: string }
*/
router.put("/memory/insights", async (req, res) => {
try {
const { content } = req.body ?? {};
if (typeof content !== "string") {
throw badRequest("content must be a string");
}
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
await writeInsightsMemory(rootDir, content);
res.json({ success: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to save memory insights");
}
});
/**
* POST /api/memory/extract
* Triggers AI-powered insight extraction from working memory.
* Reads working memory, generates insights via AI, merges/prunes existing insights,
* and generates an audit report.
*
* Returns: { success: boolean, summary: string, insightCount: number, pruned: boolean }
* Errors: 400 if working memory is empty, 503 if AI service unavailable
*/
router.post("/memory/extract", async (req, res) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any = null;
try {
await initCreateKbAgentForInsights();
if (!createKbAgentForInsights) {
throw new ApiError(503, "AI engine not available");
}
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const rootDir = scopedStore.getRootDir();
// Read working memory and existing insights
const workingMemory = await readWorkingMemory(rootDir);
const existingInsights = await readInsightsMemory(rootDir);
// Validate working memory is not empty
if (!workingMemory || workingMemory.trim().length === 0) {
throw badRequest("No working memory to extract insights from");
}
// Build the extraction prompt
const extractionPrompt = buildInsightExtractionPrompt(workingMemory, existingInsights ?? "");
// Resolve model selection hierarchy for insight extraction
const resolvedProvider =
(settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
const resolvedModelId =
(settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) ||
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
// Create AI agent session for extraction
const agentResult = await createKbAgentForInsights({
cwd: rootDir,
tools: "readonly",
defaultProvider: resolvedProvider,
defaultModelId: resolvedModelId,
systemPrompt: "You are a helpful AI assistant that extracts insights from working memory.",
});
if (!agentResult?.session) {
throw new ApiError(503, "Failed to initialize AI agent for insight extraction");
}
session = agentResult.session;
// Send extraction prompt to AI
const responseText = await session.prompt(extractionPrompt);
// Process the result: merge insights, prune duplicates, and generate audit
const result = await processAndAuditInsightExtraction(rootDir, {
rawResponse: responseText,
stepSuccess: true,
runAt: new Date().toISOString(),
});
res.json({
success: true,
summary: result.extraction?.summary ?? `Extracted ${result.extraction?.insightCount ?? 0} insights`,
insightCount: result.extraction?.insightCount ?? 0,
pruned: result.pruning?.applied ?? false,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
// Map AI service errors to 503
if (err instanceof Error && err.name === "AiServiceError") {
throw new ApiError(503, err.message || "AI service temporarily unavailable");
}
// Map other extraction errors
if (err instanceof Error && err.message.includes("No working memory")) {
throw badRequest(err.message);
}
rethrowAsApiError(err, "Failed to extract insights");
} finally {
// Always dispose the session
if (session) {
try {
session.dispose();
} catch {
// Ignore disposal errors
}
}
}
});
/**
* GET /api/memory/audit
* Returns a comprehensive memory audit report.
* The audit checks working memory and insights memory state, extraction history,
* and generates health recommendations.
*/
router.get("/memory/audit", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
const report = await generateMemoryAudit(rootDir);
res.json(report);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to generate memory audit");
}
});
/**
* GET /api/memory/stats
* Returns lightweight quick stats about memory files (no AI, no full audit).
* Useful for dashboard displays showing memory size and insight counts.
*
* Returns: { workingMemorySize: number, insightsSize: number, insightsExists: boolean }
*/
router.get("/memory/stats", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.getRootDir();
// Read both files concurrently
const [workingContent, insightsContent] = await Promise.all([
readWorkingMemory(rootDir),
readInsightsMemory(rootDir).catch(() => null),
]);
res.json({
workingMemorySize: workingContent.length,
insightsSize: insightsContent?.length ?? 0,
insightsExists: insightsContent !== null,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to fetch memory stats");
}
});
// ── Inbound Settings Sync Endpoints ────────────────────────────────
// These endpoints are called by remote nodes to deliver settings or request auth data.
// They validate apiKey auth before accepting data.