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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user