feat(FN-1130): add project memory editing in settings

- Add GET /api/memory and PUT /api/memory routes with project-scoped file access and validation
- Add fetchMemory and saveMemory dashboard API helpers for memory file operations
- Extend the Settings modal Memory section with inline FileEditor loading, dirty tracking, save action, and read-only behavior when memory is disabled
- Add SettingsModal tests covering memory load, loading state, save flow, and empty-content handling
This commit is contained in:
gsxdsm
2026-04-08 04:30:10 -07:00
parent 757942bd18
commit 4ba4cca01f
4 changed files with 236 additions and 3 deletions

View File

@@ -264,6 +264,17 @@ export function updateSettings(settings: Partial<Settings>, projectId?: string):
});
}
export function fetchMemory(projectId?: string): Promise<{ content: string }> {
return api<{ content: string }>(withProjectId("/memory", projectId));
}
export function saveMemory(content: string, projectId?: string): Promise<{ success: boolean }> {
return api<{ success: boolean }>(withProjectId("/memory", projectId), {
method: "PUT",
body: JSON.stringify({ content }),
});
}
/** Fetch global (user-level) settings from ~/.pi/fusion/settings.json */
export function fetchGlobalSettings(): Promise<GlobalSettings> {
return api<GlobalSettings>("/settings/global");

View File

@@ -17,6 +17,8 @@ const mockTestNtfyNotification = vi.fn();
const mockFetchBackups = vi.fn();
const mockCreateBackup = vi.fn();
const mockImportSettings = vi.fn();
const mockFetchMemory = vi.fn();
const mockSaveMemory = vi.fn();
vi.mock("../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
@@ -31,6 +33,8 @@ vi.mock("../api", () => ({
testNtfyNotification: (...args: unknown[]) => mockTestNtfyNotification(...args),
fetchBackups: (...args: unknown[]) => mockFetchBackups(...args),
createBackup: (...args: unknown[]) => mockCreateBackup(...args),
fetchMemory: (...args: unknown[]) => mockFetchMemory(...args),
saveMemory: (...args: unknown[]) => mockSaveMemory(...args),
}));
const noop = () => {};
@@ -67,6 +71,8 @@ describe("SettingsModal", () => {
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchBackups.mockResolvedValue({ backups: [], totalSize: 0 });
mockFetchMemory.mockResolvedValue({ content: "## Existing memory\n- Learned pattern" });
mockSaveMemory.mockResolvedValue({ success: true });
// jsdom doesn't provide URL.createObjectURL — polyfill it
if (!URL.createObjectURL) {
@@ -268,5 +274,85 @@ describe("SettingsModal", () => {
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
it("loads and shows memory editor content when navigating to Memory", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(mockFetchMemory).not.toHaveBeenCalled();
await userEvent.click(screen.getByText("Memory"));
await waitFor(() => {
expect(mockFetchMemory).toHaveBeenCalledWith(undefined);
});
const editor = await screen.findByLabelText("Editor for .fusion/memory.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Existing memory");
});
it("shows loading state while memory is being fetched", async () => {
let resolveMemory: ((value: { content: string }) => void) | undefined;
mockFetchMemory.mockReturnValueOnce(
new Promise<{ content: string }>((resolve) => {
resolveMemory = resolve;
})
);
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
expect(screen.getByText("Loading memory…")).toBeDefined();
resolveMemory?.({ content: "# Loaded" });
await waitFor(() => {
expect(screen.getByLabelText("Editor for .fusion/memory.md")).toBeDefined();
});
});
it("supports editing and saving memory content", async () => {
const addToast = vi.fn();
renderModal({ addToast });
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory.md");
fireEvent.change(editor, { target: { value: "# Updated memory\n- Reusable learning" } });
const saveButton = await screen.findByRole("button", { name: "Save Memory" });
await userEvent.click(saveButton);
await waitFor(() => {
expect(mockSaveMemory).toHaveBeenCalledWith("# Updated memory\n- Reusable learning", undefined);
});
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
});
it("handles empty memory content from API", async () => {
mockFetchMemory.mockResolvedValueOnce({ content: "" });
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
const editor = await screen.findByLabelText("Editor for .fusion/memory.md") as HTMLTextAreaElement;
expect(editor.value).toBe("");
});
});
});

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent } from "@fusion/core";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api";
import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { FileEditor } from "./FileEditor";
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
/**
@@ -107,6 +108,11 @@ export function SettingsModal({
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
const [backupLoading, setBackupLoading] = useState(false);
// Project memory state
const [memoryContent, setMemoryContent] = useState("");
const [memoryLoading, setMemoryLoading] = useState(false);
const [memoryDirty, setMemoryDirty] = useState(false);
// Import/Export state
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
@@ -162,6 +168,35 @@ export function SettingsModal({
}
}, [activeSection, projectId]);
useEffect(() => {
if (activeSection !== "memory" || memoryDirty) {
return;
}
let cancelled = false;
setMemoryLoading(true);
fetchMemory(projectId)
.then(({ content }) => {
if (cancelled) return;
setMemoryContent(content);
setMemoryDirty(false);
})
.catch((err: any) => {
if (cancelled) return;
addToast(err?.message || "Failed to load project memory", "error");
setMemoryContent("");
})
.finally(() => {
if (!cancelled) {
setMemoryLoading(false);
}
});
return () => {
cancelled = true;
};
}, [activeSection, memoryDirty, projectId, addToast]);
useEffect(() => {
if (activeSection === "authentication") {
setAuthLoading(true);
@@ -478,6 +513,16 @@ export function SettingsModal({
}
}, [form, prefixError, presetDraft, onClose, addToast, projectId]);
const handleSaveMemory = useCallback(async () => {
try {
await saveMemory(memoryContent, projectId);
setMemoryDirty(false);
addToast("Memory saved", "success");
} catch (err: any) {
addToast(err?.message || "Failed to save memory", "error");
}
}, [memoryContent, projectId, addToast]);
const savePresetDraft = () => {
if (!presetDraft) return;
@@ -1485,6 +1530,55 @@ export function SettingsModal({
</label>
<small>When enabled, agents will consult and update .fusion/memory.md with durable project learnings</small>
</div>
<div style={{ borderTop: "1px solid var(--border)", margin: "var(--space-lg) 0" }} />
<div className="form-group">
<small>This file stores durable project learnings that agents consult during triage and execution.</small>
</div>
{form.memoryEnabled === false && (
<div className="settings-empty-state" style={{ marginBottom: "var(--space-md)" }}>
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
</div>
)}
{memoryLoading ? (
<div className="settings-empty-state">Loading memory…</div>
) : (
<div className="form-group">
<div
style={{
height: "400px",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
overflow: "hidden",
}}
>
<FileEditor
content={memoryContent}
onChange={(content) => {
setMemoryContent(content);
setMemoryDirty(true);
}}
readOnly={form.memoryEnabled === false}
filePath=".fusion/memory.md"
/>
</div>
</div>
)}
{memoryDirty && (
<div className="form-group">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleSaveMemory}
>
Save Memory
</button>
</div>
)}
</>
);
case "backups":

View File

@@ -6,13 +6,13 @@ import { resolve, sep, join } from "node:path";
import * as nodeFs from "node:fs";
import * as nodeChildProcess from "node:child_process";
import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, MEMORY_FILE_PATH } from "@fusion/core";
import type { ServerOptions } from "./server.js";
import { GitHubClient, getCurrentGitHubRepo, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse, type FileOperationResponse } from "./file-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, readProjectFile, writeProjectFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse, type FileOperationResponse } from "./file-service.js";
import { fetchAllProviderUsage } from "./usage.js";
import {
getGitHubAppConfig,
@@ -1428,6 +1428,48 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Project Memory Routes ─────────────────────────────────────
/**
* GET /api/memory
* Returns the project memory file content.
* If .fusion/memory.md does not exist yet, returns an empty string.
*/
router.get("/memory", async (req, res) => {
try {
const scopedStore = await getScopedStore(req);
const memory = await readProjectFile(scopedStore, MEMORY_FILE_PATH);
res.json({ content: memory.content });
} catch (err: any) {
if (err instanceof FileServiceError && err.code === "ENOENT") {
res.json({ content: "" });
return;
}
res.status(500).json({ error: err.message ?? "Failed to read memory" });
}
});
/**
* PUT /api/memory
* Updates the project memory file content.
* Body: { content: string }
*/
router.put("/memory", async (req, res) => {
try {
const { content } = req.body ?? {};
if (typeof content !== "string") {
res.status(400).json({ error: "content must be a string" });
return;
}
const scopedStore = await getScopedStore(req);
await writeProjectFile(scopedStore, MEMORY_FILE_PATH, content);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message ?? "Failed to save memory" });
}
});
// ── Global Settings Routes ─────────────────────────────────────
/**