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:
@@ -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");
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user