fix(FN-000): compact selected memory file

This commit is contained in:
gsxdsm
2026-04-17 08:55:50 -07:00
parent 2956998eb5
commit 9b42551bac
9 changed files with 131 additions and 81 deletions

View File

@@ -12804,13 +12804,20 @@ describe("PUT /api/memory", () => {
describe("POST /api/memory/compact", () => {
let store: TaskStore;
let rootDir: string;
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "fusion-memory-compact-"));
mkdirSync(join(rootDir, ".fusion", "memory"), { recursive: true });
store = createMockStore({
getRootDir: vi.fn().mockReturnValue("/test/project"),
getRootDir: vi.fn().mockReturnValue(rootDir),
});
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
function buildApp() {
const app = express();
app.use(express.json());
@@ -12823,26 +12830,9 @@ describe("POST /api/memory/compact", () => {
memoryEnabled: true,
memoryBackendType: "file",
});
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue("/tmp/test");
writeFileSync(join(rootDir, ".fusion", "memory", "DREAMS.md"), "Short content");
// Mock the memory-backend module to return short content
vi.doMock("../../core/src/memory-backend.js", () => ({
readMemory: vi.fn().mockResolvedValue({ content: "Short content", exists: true, backend: "file" }),
writeMemory: vi.fn().mockResolvedValue({ success: true, backend: "file" }),
resolveMemoryBackend: vi.fn(),
MemoryBackendError: class MemoryBackendError extends Error {
code: string;
backend: string;
constructor(code: string, message: string, backend: string) {
super(message);
this.name = "MemoryBackendError";
this.code = code;
this.backend = backend;
}
},
}));
const res = await REQUEST(buildApp(), "POST", "/api/memory/compact", "", {
const res = await REQUEST(buildApp(), "POST", "/api/memory/compact", JSON.stringify({ path: ".fusion/memory/DREAMS.md" }), {
"Content-Type": "application/json",
});
@@ -12850,29 +12840,23 @@ describe("POST /api/memory/compact", () => {
expect(res.body.error).toContain("too short to compact");
});
// Note: Full AI integration tests for successful compaction and AI failure
// are covered in the core package tests (memory-compaction.test.ts).
// This test follows the same pattern as POST /api/ai/summarize-title:
// accept [200, 503] since the AI service may not be available in the test environment.
// The vi.doMock from the previous test persists, returning short content → 400.
it("accepts compaction request (returns 200 or 503)", async () => {
it("returns 409 for read-only memory backends before compacting", async () => {
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
memoryEnabled: true,
memoryBackendType: "file",
memoryBackendType: "readonly",
});
(store.getRootDir as ReturnType<typeof vi.fn>).mockReturnValue("/test/project");
writeFileSync(join(rootDir, ".fusion", "memory", "MEMORY.md"), "Long memory content.\n".repeat(20));
const res = await REQUEST(
buildApp(),
"POST",
"/api/memory/compact",
JSON.stringify({}),
JSON.stringify({ path: ".fusion/memory/MEMORY.md" }),
{ "Content-Type": "application/json" },
);
// vi.doMock persists, content is short → 400
// This test verifies the route exists and accepts requests
expect([200, 400, 503]).toContain(res.status);
expect(res.status).toBe(409);
expect(res.body.error).toContain("read-only");
});
});

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, 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, QMD_INSTALL_COMMAND, MemoryBackendError, 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";
@@ -2714,9 +2714,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
/**
* POST /api/memory/compact
* AI-powered memory compaction using the memory compaction service.
* Reads current memory, compacts it using AI, and writes back the result.
* Reads one selected memory file, compacts it using AI, and writes back the result.
*
* Body: none (reads current memory from backend)
* Body: { path?: string } (defaults to .fusion/memory/MEMORY.md)
*
* Error mapping:
* - Memory content too short (< 200 chars) → 400 Bad Request
@@ -2727,12 +2727,19 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
*/
router.post("/memory/compact", async (req, res) => {
try {
const requestedPath = typeof req.body?.path === "string" && req.body.path.trim()
? req.body.path
: ".fusion/memory/MEMORY.md";
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettings();
const backend = resolveMemoryBackend(settings);
if (!backend.capabilities.writable) {
throw new MemoryBackendError("READ_ONLY", "This backend is read-only and cannot write memory", backend.type);
}
const rootDir = scopedStore.getRootDir();
// Read current memory
const result = await readMemory(rootDir, settings);
// Read the selected file in full before compaction.
const result = await readProjectMemoryFileContent(rootDir, requestedPath);
const content = result.content;
// Validate content length (must be at least 200 chars to compact)
@@ -2759,10 +2766,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const { compactMemoryWithAi } = await import("@fusion/core");
const compacted = await compactMemoryWithAi(content, rootDir, resolvedProvider, resolvedModelId);
// Write compacted content back
await writeMemory(rootDir, compacted, settings);
// Write compacted content back to the same selected memory file.
await writeProjectMemoryFile(rootDir, result.path, compacted);
res.json({ content: compacted });
res.json({ path: result.path, content: compacted });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;